From 3269aea6c9a8483f3730c4269d645f8b6c7ba17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= Date: Thu, 8 Oct 2026 01:20:49 +0200 Subject: [PATCH] feat(marketing): add a render route for graphics with state from the URL /marketing/render shows one graphic, without the Console shell, built from URL parameters: template, format and the template's field values. It reuses the editor's templates, validation and rasterizer, and reports readiness, text overflow and errors through data-render-* attributes on the root element so scripts can wait for a result instead of a timeout. A background photo cannot travel in a URL, so photo=1 makes the page wait for a file in its file input. Closes #6 Co-Authored-By: Claude Opus 5.5 --- README.md | 37 +++ e2e/marketing/render.spec.ts | 236 +++++++++++++++++ .../GraphicRender/GraphicRender.constants.ts | 16 ++ .../GraphicRender/GraphicRender.test.tsx | 134 ++++++++++ .../marketing/GraphicRender/GraphicRender.tsx | 76 ++++++ .../GraphicRender/GraphicRender.types.ts | 42 +++ .../utils/parseRenderParams.test.ts | 141 +++++++++++ .../GraphicRender/utils/parseRenderParams.ts | 138 ++++++++++ .../utils/useRenderedGraphic.test.ts | 239 ++++++++++++++++++ .../GraphicRender/utils/useRenderedGraphic.ts | 78 ++++++ src/pages/marketing_.render.tsx | 5 + src/root.tsx | 15 +- src/types/console.ts | 5 + 13 files changed, 1159 insertions(+), 3 deletions(-) create mode 100644 e2e/marketing/render.spec.ts create mode 100644 src/components/marketing/GraphicRender/GraphicRender.constants.ts create mode 100644 src/components/marketing/GraphicRender/GraphicRender.test.tsx create mode 100644 src/components/marketing/GraphicRender/GraphicRender.tsx create mode 100644 src/components/marketing/GraphicRender/GraphicRender.types.ts create mode 100644 src/components/marketing/GraphicRender/utils/parseRenderParams.test.ts create mode 100644 src/components/marketing/GraphicRender/utils/parseRenderParams.ts create mode 100644 src/components/marketing/GraphicRender/utils/useRenderedGraphic.test.ts create mode 100644 src/components/marketing/GraphicRender/utils/useRenderedGraphic.ts create mode 100644 src/pages/marketing_.render.tsx diff --git a/README.md b/README.md index dfdf46d..da9316a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,43 @@ Front-end standards live in [CLAUDE.md](CLAUDE.md). PNGs are produced by rasterizing the rendered template with `modern-screenshot`, so the preview is exactly the file you download. +## Render route (Marketing) + +`/marketing/render` shows one graphic with its state taken from the URL, without the Console shell. It exists for scripts and agents that need a specific PNG without clicking through the editor. It runs the editor's own templates, validation and rasterizer, so the same values give the same image. + +```txt +/marketing/render?template=standard&format=portrait&title=Nowy%20*projekt*&funding=none +``` + +| Parameter | Required | Value | +| --- | --- | --- | +| `template` | yes | A template id from `GRAPHIC_TEMPLATES`: `standard`, `news`. | +| `format` | yes | A format id from `GRAPHIC_FORMATS`: `square` (1080×1080), `portrait` (1080×1350), `story` (1080×1920), `landscape` (1200×628). | +| _field id_ | no | A value for one of the template's `fields`, under the same `id` as in the editor. A missing field keeps its `defaultValue`. Wrap a fragment in `*` to highlight it; encode a line break as `%0A`. | +| `photo` | no | `1` announces a background photo. The file cannot travel in a URL, so the page then waits for it in its file input (see below). | +| `focalX`, `focalY` | no | Focal point of the photo in percent, 0-100, default 50. Only with `photo=1`. | + +Fields per template: + +- **standard:** `title`, `titleSize` (32, 48, 64, 80), `subtitle`, `subtitleSize` (24, 32, 40, 48), `position` (top, middle, bottom), `align` (left, center, right), `funding` (none, proo). +- **news:** `personName`, `title`, `newsTitleSize` (32, 48, 64, 80), `subtitle`, `subtitleSize` (24, 32, 40, 48), `funding` (none, proo). + +The result is reported on the page's root element, `
`: + +| Attribute | Meaning | +| --- | --- | +| `data-render-status` | `loading` while fonts load and the graphic is rasterized, `awaiting-photo` until a file is put into the file input, then `ready` or `error`. Wait for one of the last two instead of a timeout. | +| `data-render-template`, `data-render-format`, `data-render-width`, `data-render-height` | What was requested, once the URL is understood. | +| `data-render-overflow` | With `ready`: `true` when text does not fit a box marked with `data-fit`. The PNG is still produced; shorten the text or pick a smaller size and render again. | +| `data-render-error` | With `error`: `missing-parameter`, `duplicate-parameter`, `unknown-template`, `unknown-format`, `unknown-parameter`, `invalid-photo`, `invalid-content` or `render-failed`. The message (in Polish, like the editor's) is in the `role="alert"` element. | +| `data-render-error-fields` | With `invalid-content`: the ids of the fields to fix, separated by spaces. | + +When ready, the page contains a single `` whose `src` is a blob URL of the PNG at the format's exact pixel size. Read the file with `fetch(img.src)` inside the page; a screenshot of the page is not the export. + +**Background photo:** open the URL with `photo=1`, wait for `data-render-status="awaiting-photo"`, put a JPG, PNG or WebP into the `input[type=file]` (Playwright: `setInputFiles`), then wait for `ready`. The photo goes through the editor's own downscaling. + +The route is a prerendered static page like the others, so it works under `CONSOLE_BASE_PATH` and on GitHub Pages. + ## Dependencies pending Technical Leader approval On top of the stack in CLAUDE.md §1, the project adds `@fortawesome/*` (solid icons) and `modern-screenshot` (HTML template to PNG export). Both need TL approval. diff --git a/e2e/marketing/render.spec.ts b/e2e/marketing/render.spec.ts new file mode 100644 index 0000000..89a51be --- /dev/null +++ b/e2e/marketing/render.spec.ts @@ -0,0 +1,236 @@ +import { expect, type Page, test } from "@playwright/test"; +import { stubBufferWorker } from "../bufferWorker"; + +const FORMATS = [ + { name: "Post kwadratowy", id: "square", width: 1080, height: 1080 }, + { name: "Post pionowy", id: "portrait", width: 1080, height: 1350 }, + { name: "Story / Reels", id: "story", width: 1080, height: 1920 }, + { name: "Post poziomy", id: "landscape", width: 1200, height: 628 }, +]; +const TEMPLATES = ["standard", "news"]; +// The editor and the route share one rasterizer, so the PNGs match byte for byte +// except where the browser resamples the downscaled funding banner differently. +const SAME_IMAGE_TOLERANCE = 0.01; + +const renderUrl = (params: Record) => + `./marketing/render?${new URLSearchParams(params)}`; + +const root = (page: Page) => page.getByRole("main"); + +const waitForStatus = (page: Page, status: string) => + expect(root(page)).toHaveAttribute("data-render-status", status); + +/** The bytes behind an image's blob URL, i.e. the exported PNG itself. */ +const pngBytes = async (page: Page, name?: RegExp) => + Buffer.from( + await page + .getByRole("img", { name }) + .first() + .evaluate(async (img: HTMLImageElement) => { + const blob = await (await fetch(img.src)).blob(); + // Base64 crosses the Playwright bridge much faster than a number array. + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(",")[1]); + reader.readAsDataURL(blob); + }); + }), + "base64", + ); + +/** Share of pixels that differ between two PNGs of the same size. */ +const differingShare = async (page: Page, first: Buffer, second: Buffer) => + first.equals(second) + ? 0 + : page.evaluate( + async (images) => { + const [left, right] = await Promise.all( + images.map(async (base64) => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/png;base64,${base64}`)).blob(), + ); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d")!; + context.drawImage(bitmap, 0, 0); + return context.getImageData(0, 0, bitmap.width, bitmap.height); + }), + ); + if (left.width !== right.width || left.height !== right.height) + return 1; + let differing = 0; + for (let offset = 0; offset < left.data.length; offset += 4) + if ( + left.data[offset] !== right.data[offset] || + left.data[offset + 1] !== right.data[offset + 1] || + left.data[offset + 2] !== right.data[offset + 2] || + left.data[offset + 3] !== right.data[offset + 3] + ) + differing++; + return differing / (left.width * left.height); + }, + [first.toString("base64"), second.toString("base64")], + ); + +test.describe("Feature: Marketing graphic render route", () => { + for (const template of TEMPLATES) { + test(`Scenario: ${template} renders in every format at its pixel size`, async ({ + page, + }) => { + for (const format of FORMATS) { + await test.step(`Given the render URL of ${template} in ${format.id}`, async () => { + await page.goto(renderUrl({ template, format: format.id })); + }); + + await test.step("Then only the PNG of that size is shown", async () => { + await waitForStatus(page, "ready"); + await expect(root(page)).toHaveAttribute( + "data-render-overflow", + "false", + ); + const bytes = await pngBytes(page); + expect(bytes.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); + expect(bytes.readUInt32BE(16)).toBe(format.width); + expect(bytes.readUInt32BE(20)).toBe(format.height); + await expect(page.getByRole("navigation")).toHaveCount(0); + await expect(page.getByRole("banner")).toHaveCount(0); + }); + } + }); + } + + test("Scenario: the route and the editor produce the same image", async ({ + page, + }) => { + const title = "Zażółć *gęślą* jaźń"; + let editorBytes: Buffer[] = []; + + await test.step("Given the editor exported a graphic with an own headline", async () => { + await stubBufferWorker(page); + await page.goto("./marketing"); + await page.getByLabel("Tytuł", { exact: true }).fill(title); + await expect( + page.getByRole("img", { name: /^Post poziomy: Zażółć gęślą jaźń/ }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: /^Pobierz/ })).toHaveCount(4); + editorBytes = []; + for (const format of FORMATS) + editorBytes.push( + await pngBytes(page, new RegExp(`^${format.name}: Zażółć`)), + ); + }); + + for (const [index, format] of FORMATS.entries()) { + await test.step(`Then the render URL gives the same ${format.id} PNG`, async () => { + await page.goto( + renderUrl({ template: "standard", format: format.id, title }), + ); + await waitForStatus(page, "ready"); + const share = await differingShare( + page, + await pngBytes(page), + editorBytes[index], + ); + console.log( + `${format.id}: ${(share * 100).toFixed(3)}% of pixels differ`, + ); + expect(share).toBeLessThan(SAME_IMAGE_TOLERANCE); + }); + } + }); + + test("Scenario: text that does not fit is reported, not hidden", async ({ + page, + }) => { + await page.goto( + renderUrl({ + template: "standard", + format: "landscape", + subtitle: "Wspólnie tworzymy zmiany dla młodych ludzi. ".repeat(8), + }), + ); + await waitForStatus(page, "ready"); + await expect(root(page)).toHaveAttribute("data-render-overflow", "true"); + await expect(page.getByRole("img")).toBeVisible(); + }); + + test("Scenario: a wrong URL explains itself", async ({ page }) => { + await page.goto(renderUrl({ template: "poster", format: "square" })); + await waitForStatus(page, "error"); + await expect(root(page)).toHaveAttribute( + "data-render-error", + "unknown-template", + ); + await expect(page.getByRole("alert")).toHaveText( + 'Nieznany szablon "poster". Dostępne: standard, news.', + ); + + await page.goto( + renderUrl({ template: "standard", format: "square", align: "justify" }), + ); + await waitForStatus(page, "error"); + await expect(root(page)).toHaveAttribute( + "data-render-error", + "invalid-content", + ); + await expect(root(page)).toHaveAttribute( + "data-render-error-fields", + "align", + ); + }); + + test("Scenario: a background photo is injected as a file", async ({ + page, + }) => { + const pixel = () => + page.getByRole("img").evaluate((img: HTMLImageElement) => { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const context = canvas.getContext("2d")!; + context.drawImage(img, 0, 0); + return Array.from(context.getImageData(540, 300, 1, 1).data); + }); + + await test.step("Given a render URL that announces a photo framed on its right edge", async () => { + await page.goto( + renderUrl({ + template: "standard", + format: "square", + photo: "1", + focalX: "95", + }), + ); + await waitForStatus(page, "awaiting-photo"); + await expect(page.getByRole("img")).toHaveCount(0); + }); + + await test.step("When the automation puts a half-red, half-blue photo into the file input", async () => { + const fixture = await page.evaluate(() => { + const canvas = document.createElement("canvas"); + canvas.width = 800; + canvas.height = 400; + const context = canvas.getContext("2d")!; + context.fillStyle = "#ff0000"; + context.fillRect(0, 0, 400, 400); + context.fillStyle = "#0000ff"; + context.fillRect(400, 0, 400, 400); + return canvas.toDataURL("image/png").split(",")[1]; + }); + await page.getByLabel("Zdjęcie w tle").setInputFiles({ + name: "crop.png", + mimeType: "image/png", + buffer: Buffer.from(fixture, "base64"), + }); + }); + + await test.step("Then the graphic shows the blue part of the photo", async () => { + await waitForStatus(page, "ready"); + // The overlay darkens the photo, so compare channels instead of exact colours. + const [red, , blue] = await pixel(); + expect(blue).toBeGreaterThan(60); + expect(red).toBeLessThan(20); + }); + }); +}); diff --git a/src/components/marketing/GraphicRender/GraphicRender.constants.ts b/src/components/marketing/GraphicRender/GraphicRender.constants.ts new file mode 100644 index 0000000..3111783 --- /dev/null +++ b/src/components/marketing/GraphicRender/GraphicRender.constants.ts @@ -0,0 +1,16 @@ +/** URL parameters that are not template fields. */ +export const RENDER_PARAMS = { + template: "template", + format: "format", + photo: "photo", + focalX: "focalX", + focalY: "focalY", +} as const; + +/** The only accepted value of `photo`: a file will be put into the page's file input. */ +export const PHOTO_EXPECTED = "1"; + +/** Focal point in percent of the photo, as in the editor's picker. */ +export const FOCAL_MIN = 0; +export const FOCAL_MAX = 100; +export const FOCAL_DEFAULT = 50; diff --git a/src/components/marketing/GraphicRender/GraphicRender.test.tsx b/src/components/marketing/GraphicRender/GraphicRender.test.tsx new file mode 100644 index 0000000..9fc3656 --- /dev/null +++ b/src/components/marketing/GraphicRender/GraphicRender.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { downscalePhoto } from "../ExportLab/utils/downscalePhoto"; +import { loadGraphicFonts } from "../ExportLab/utils/loadGraphicFonts"; +import { rasterizeGraphic } from "../ExportLab/utils/rasterizeGraphic"; +import GraphicRender from "./GraphicRender"; + +vi.mock("../ExportLab/utils/loadGraphicFonts", () => ({ + loadGraphicFonts: vi.fn(), +})); +vi.mock("../ExportLab/utils/rasterizeGraphic", () => ({ + rasterizeGraphic: vi.fn(), +})); +vi.mock("../ExportLab/utils/downscalePhoto", () => ({ + downscalePhoto: vi.fn(), +})); + +const renderAt = (query: string) => + render( + + + , + ); + +const root = () => screen.getByRole("main"); + +describe("", () => { + beforeEach(() => { + vi.mocked(loadGraphicFonts).mockResolvedValue(); + vi.mocked(rasterizeGraphic).mockResolvedValue({ + blob: new Blob(["png"]), + hasOverflow: false, + }); + vi.mocked(downscalePhoto).mockImplementation(async (file) => file); + let urlCount = 0; + URL.createObjectURL = vi.fn(() => `blob:${++urlCount}`); + URL.revokeObjectURL = vi.fn(); + }); + + describe("when the URL describes a graphic", () => { + it("shows only the PNG at the format's size and signals readiness", async () => { + renderAt("template=standard&format=landscape&title=Nowy%20tytuł"); + await waitFor(() => + expect(root()).toHaveAttribute("data-render-status", "ready"), + ); + expect(root()).toHaveAttribute("data-render-template", "standard"); + expect(root()).toHaveAttribute("data-render-format", "landscape"); + expect(root()).toHaveAttribute("data-render-width", "1200"); + expect(root()).toHaveAttribute("data-render-height", "628"); + expect(root()).toHaveAttribute("data-render-overflow", "false"); + expect(root()).not.toHaveAttribute("data-render-error"); + const image = screen.getByRole("img", { + name: /^Nowy tytuł\. Łączymy .* Finansowanie: PROO\.$/, + }); + expect(image).toHaveAttribute("src", "blob:1"); + expect(image).toHaveAttribute("width", "1200"); + expect(image).toHaveAttribute("height", "628"); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByLabelText("Zdjęcie w tle")).toBeNull(); + }); + + it("exposes text that does not fit", async () => { + vi.mocked(rasterizeGraphic).mockResolvedValue({ + blob: new Blob(["png"]), + hasOverflow: true, + }); + renderAt("template=news&format=square"); + await waitFor(() => + expect(root()).toHaveAttribute("data-render-overflow", "true"), + ); + }); + }); + + describe("when the URL cannot be rendered", () => { + it("explains the problem instead of a blank page", async () => { + renderAt("template=standard&format=square&position=diagonal"); + await waitFor(() => + expect(root()).toHaveAttribute("data-render-status", "error"), + ); + expect(root()).toHaveAttribute("data-render-error", "invalid-content"); + expect(root()).toHaveAttribute("data-render-error-fields", "position"); + expect(root()).not.toHaveAttribute("data-render-overflow"); + expect(screen.getByRole("alert")).toHaveTextContent( + "Wybierz: położenie tekstu.", + ); + expect(screen.queryByRole("img")).toBeNull(); + expect(rasterizeGraphic).not.toHaveBeenCalled(); + }); + }); + + describe("when the URL announces a photo", () => { + it("waits for the file and renders with it", async () => { + renderAt("template=standard&format=square&photo=1&focalX=20"); + await waitFor(() => + expect(root()).toHaveAttribute("data-render-status", "awaiting-photo"), + ); + expect(rasterizeGraphic).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText("Zdjęcie w tle"), { + target: { + files: [new File(["photo"], "photo.png", { type: "image/png" })], + }, + }); + await waitFor(() => + expect(root()).toHaveAttribute("data-render-status", "ready"), + ); + expect(rasterizeGraphic).toHaveBeenCalledWith( + expect.objectContaining({ id: "standard" }), + expect.objectContaining({ + photo: { url: "blob:1", focalX: 20, focalY: 50 }, + }), + expect.objectContaining({ id: "square" }), + ); + }); + + it("fails on a file type the editor would refuse", async () => { + renderAt("template=standard&format=square&photo=1"); + await waitFor(() => + expect(root()).toHaveAttribute("data-render-status", "awaiting-photo"), + ); + fireEvent.change(screen.getByLabelText("Zdjęcie w tle"), { + target: { + files: [new File(["photo"], "photo.heic", { type: "image/heic" })], + }, + }); + await waitFor(() => + expect(root()).toHaveAttribute("data-render-error", "invalid-photo"), + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Wybierz JPG, PNG lub WebP.", + ); + }); + }); +}); diff --git a/src/components/marketing/GraphicRender/GraphicRender.tsx b/src/components/marketing/GraphicRender/GraphicRender.tsx new file mode 100644 index 0000000..6fdfaa3 --- /dev/null +++ b/src/components/marketing/GraphicRender/GraphicRender.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router"; +import { PHOTO_ACCEPTED_TYPES } from "../ExportLab/ExportLab.constants"; +import { describeContent } from "../ExportLab/utils/describeContent"; +import { usePhoto } from "../ExportLab/utils/usePhoto"; +import type { ParsedRenderParams, RenderState } from "./GraphicRender.types"; +import { parseRenderParams } from "./utils/parseRenderParams"; +import { useRenderedGraphic } from "./utils/useRenderedGraphic"; + +/** + * One graphic with its state taken from the URL, for scripts and agents. + * The `data-render-*` attributes on the root are the contract; see the README. + */ +const GraphicRender = () => { + const [searchParams] = useSearchParams(); + // The prerendered HTML has no query string; parsing after mount keeps hydration identical. + const [parsed, setParsed] = useState(null); + useEffect(() => { + setParsed(parseRenderParams(searchParams)); + }, [searchParams]); + + const photo = usePhoto(); + const request = parsed?.request ?? null; + const graphic = useRenderedGraphic(request, photo); + const state: RenderState = parsed?.error + ? { status: "error", error: parsed.error } + : parsed + ? graphic + : { status: "loading" }; + const failure = state.status === "error" ? state.error : null; + + return ( +
+ {request && state.status === "ready" && ( + {describeContent(request.template, + )} + {failure && ( +

+ {failure.message} +

+ )} + {request?.photoFocus && ( + + )} +
+ ); +}; + +export default GraphicRender; diff --git a/src/components/marketing/GraphicRender/GraphicRender.types.ts b/src/components/marketing/GraphicRender/GraphicRender.types.ts new file mode 100644 index 0000000..cf726f8 --- /dev/null +++ b/src/components/marketing/GraphicRender/GraphicRender.types.ts @@ -0,0 +1,42 @@ +import type { + FieldValues, + GraphicFormat, + GraphicPhoto, + GraphicTemplate, +} from "../ExportLab/ExportLab.types"; + +/** Machine-readable reason, exposed as `data-render-error` for automation. */ +export type RenderErrorCode = + | "missing-parameter" + | "duplicate-parameter" + | "unknown-template" + | "unknown-format" + | "unknown-parameter" + | "invalid-photo" + | "invalid-content" + | "render-failed"; + +export interface RenderFailure { + code: RenderErrorCode; + message: string; + /** Template fields to fix; empty when no field is to blame. */ + fieldIds: readonly string[]; +} + +export interface RenderRequest { + template: GraphicTemplate; + format: GraphicFormat; + values: FieldValues; + /** Set when the URL announces a photo; the file itself arrives through the file input. */ + photoFocus: Pick | null; +} + +export type ParsedRenderParams = + | { request: RenderRequest; error?: undefined } + | { request?: undefined; error: RenderFailure }; + +export type RenderState = + | { status: "loading" } + | { status: "awaiting-photo" } + | { status: "ready"; url: string; hasOverflow: boolean } + | { status: "error"; error: RenderFailure }; diff --git a/src/components/marketing/GraphicRender/utils/parseRenderParams.test.ts b/src/components/marketing/GraphicRender/utils/parseRenderParams.test.ts new file mode 100644 index 0000000..afc5ad5 --- /dev/null +++ b/src/components/marketing/GraphicRender/utils/parseRenderParams.test.ts @@ -0,0 +1,141 @@ +import { getDefaultFieldValues } from "../../ExportLab/utils/getDefaultFieldValues"; +import { parseRenderParams } from "./parseRenderParams"; + +const parse = (query: string) => parseRenderParams(new URLSearchParams(query)); + +describe("parseRenderParams", () => { + describe("when only the template and the format are given", () => { + it("uses the editor's default values and expects no photo", () => { + const { request, error } = parse("template=standard&format=portrait"); + expect(error).toBeUndefined(); + expect(request?.template.id).toBe("standard"); + expect(request?.format).toMatchObject({ + id: "portrait", + width: 1080, + height: 1350, + }); + expect(request?.values).toEqual(getDefaultFieldValues()); + expect(request?.photoFocus).toBeNull(); + }); + }); + + describe("when field values are given", () => { + it("overrides only those fields, keeping line breaks and markers", () => { + const { request } = parse( + `template=news&format=story&title=${encodeURIComponent("Nowy *projekt*\nrusza")}&newsTitleSize=64&funding=none&personName=`, + ); + expect(request?.values).toEqual({ + ...getDefaultFieldValues(), + title: "Nowy *projekt*\nrusza", + newsTitleSize: "64", + funding: "none", + personName: "", + }); + }); + }); + + describe("when a photo is announced", () => { + it("centres the focal point by default", () => { + expect( + parse("template=standard&format=square&photo=1").request?.photoFocus, + ).toEqual({ focalX: 50, focalY: 50 }); + }); + + it("takes the focal point from the URL", () => { + expect( + parse("template=standard&format=square&photo=1&focalX=5&focalY=87.5") + .request?.photoFocus, + ).toEqual({ focalX: 5, focalY: 87.5 }); + }); + + it.each([ + ["photo=yes", 'Parametr "photo" przyjmuje tylko wartość 1.'], + ["photo=1&focalX=101", 'Parametr "focalX" musi być liczbą od 0 do 100.'], + ["photo=1&focalY=-1", 'Parametr "focalY" musi być liczbą od 0 do 100.'], + ["photo=1&focalX=left", 'Parametr "focalX" musi być liczbą od 0 do 100.'], + ["photo=1&focalY=", 'Parametr "focalY" musi być liczbą od 0 do 100.'], + ["focalX=20", 'Parametr "focalX" wymaga photo=1.'], + ["focalY=20", 'Parametr "focalY" wymaga photo=1.'], + ])("rejects %s", (query, message) => { + expect(parse(`template=standard&format=square&${query}`)).toEqual({ + error: { code: "invalid-photo", message, fieldIds: [] }, + }); + }); + }); + + describe("when the template or the format is missing", () => { + it.each([ + ["format=square", 'Podaj parametr "template".'], + ["template=standard", 'Podaj parametr "format".'], + ["template=&format=square", 'Podaj parametr "template".'], + ])("asks for it in %s", (query, message) => { + expect(parse(query)).toEqual({ + error: { code: "missing-parameter", message, fieldIds: [] }, + }); + }); + }); + + describe("when the template is unknown", () => { + it("lists the available templates", () => { + expect(parse("template=poster&format=square")).toEqual({ + error: { + code: "unknown-template", + message: 'Nieznany szablon "poster". Dostępne: standard, news.', + fieldIds: [], + }, + }); + }); + }); + + describe("when the format is unknown", () => { + it("lists the available formats", () => { + expect(parse("template=standard&format=banner")).toEqual({ + error: { + code: "unknown-format", + message: + 'Nieznany format "banner". Dostępne: square, portrait, story, landscape.', + fieldIds: [], + }, + }); + }); + }); + + describe("when a parameter is not a field of the template", () => { + it("lists the template's fields", () => { + expect(parse("template=standard&format=square&personName=Ala")).toEqual({ + error: { + code: "unknown-parameter", + message: + 'Nieznany parametr "personName". Pola szablonu Standard: title, titleSize, subtitle, subtitleSize, position, align, funding.', + fieldIds: [], + }, + }); + }); + }); + + describe("when a parameter is repeated", () => { + it("refuses to guess which value was meant", () => { + expect(parse("template=standard&format=square&title=A&title=B")).toEqual({ + error: { + code: "duplicate-parameter", + message: 'Parametr "title" podano więcej niż raz.', + fieldIds: [], + }, + }); + }); + }); + + describe("when a value breaks the editor's rules", () => { + it("blames the field with the editor's message", () => { + expect( + parse("template=standard&format=square&position=diagonal"), + ).toEqual({ + error: { + code: "invalid-content", + message: "Wybierz: położenie tekstu.", + fieldIds: ["position"], + }, + }); + }); + }); +}); diff --git a/src/components/marketing/GraphicRender/utils/parseRenderParams.ts b/src/components/marketing/GraphicRender/utils/parseRenderParams.ts new file mode 100644 index 0000000..a2f7318 --- /dev/null +++ b/src/components/marketing/GraphicRender/utils/parseRenderParams.ts @@ -0,0 +1,138 @@ +import { + GRAPHIC_FORMATS, + GRAPHIC_TEMPLATES, +} from "../../ExportLab/ExportLab.constants"; +import { ContentError } from "../../ExportLab/utils/contentError"; +import { getDefaultFieldValues } from "../../ExportLab/utils/getDefaultFieldValues"; +import { validateContent } from "../../ExportLab/utils/validateContent"; +import { + FOCAL_DEFAULT, + FOCAL_MAX, + FOCAL_MIN, + PHOTO_EXPECTED, + RENDER_PARAMS, +} from "../GraphicRender.constants"; +import type { + ParsedRenderParams, + RenderErrorCode, + RenderFailure, + RenderRequest, +} from "../GraphicRender.types"; + +const RESERVED = new Set(Object.values(RENDER_PARAMS)); + +const fail = ( + code: RenderErrorCode, + message: string, + fieldIds: readonly string[] = [], +): { error: RenderFailure } => ({ error: { code, message, fieldIds } }); + +const listIds = (items: readonly { id: string }[]) => + items.map((item) => item.id).join(", "); + +function parseFocal(name: string, raw: string | null) { + if (raw === null) return FOCAL_DEFAULT; + const value = Number(raw); + return raw.trim() !== "" && value >= FOCAL_MIN && value <= FOCAL_MAX + ? value + : fail( + "invalid-photo", + `Parametr "${name}" musi być liczbą od ${FOCAL_MIN} do ${FOCAL_MAX}.`, + ); +} + +/** + * Turns the render route's query string into what the editor keeps in state: + * a template, a format and field values, validated by the editor's own rules. + */ +export function parseRenderParams(params: URLSearchParams): ParsedRenderParams { + const names = [...new Set(params.keys())]; + const duplicate = names.find((name) => params.getAll(name).length > 1); + if (duplicate) + return fail( + "duplicate-parameter", + `Parametr "${duplicate}" podano więcej niż raz.`, + ); + + for (const name of [RENDER_PARAMS.template, RENDER_PARAMS.format]) + if (!params.get(name)) + return fail("missing-parameter", `Podaj parametr "${name}".`); + + const templateId = params.get(RENDER_PARAMS.template); + const template = GRAPHIC_TEMPLATES.find((item) => item.id === templateId); + if (!template) + return fail( + "unknown-template", + `Nieznany szablon "${templateId}". Dostępne: ${listIds(GRAPHIC_TEMPLATES)}.`, + ); + + const formatId = params.get(RENDER_PARAMS.format); + const format = GRAPHIC_FORMATS.find((item) => item.id === formatId); + if (!format) + return fail( + "unknown-format", + `Nieznany format "${formatId}". Dostępne: ${listIds(GRAPHIC_FORMATS)}.`, + ); + + const fieldIds = new Set(template.fields.map((field) => field.id)); + const unknown = names.find( + (name) => !RESERVED.has(name) && !fieldIds.has(name), + ); + if (unknown) + return fail( + "unknown-parameter", + `Nieznany parametr "${unknown}". Pola szablonu ${template.name}: ${listIds(template.fields)}.`, + ); + + // Same starting point as the editor, so equal values give an equal graphic. + const values = getDefaultFieldValues(); + for (const id of fieldIds) { + const value = params.get(id); + if (value !== null) values[id] = value; + } + + const photo = params.get(RENDER_PARAMS.photo); + let photoFocus: RenderRequest["photoFocus"] = null; + if (photo === null) { + const stray = [RENDER_PARAMS.focalX, RENDER_PARAMS.focalY].find((name) => + params.has(name), + ); + if (stray) + return fail( + "invalid-photo", + `Parametr "${stray}" wymaga ${RENDER_PARAMS.photo}=${PHOTO_EXPECTED}.`, + ); + } else { + if (photo !== PHOTO_EXPECTED) + return fail( + "invalid-photo", + `Parametr "${RENDER_PARAMS.photo}" przyjmuje tylko wartość ${PHOTO_EXPECTED}.`, + ); + if (!template.supportsPhoto) + return fail( + "invalid-photo", + `Szablon ${template.name} nie obsługuje zdjęcia.`, + ); + const focalX = parseFocal( + RENDER_PARAMS.focalX, + params.get(RENDER_PARAMS.focalX), + ); + if (typeof focalX !== "number") return focalX; + const focalY = parseFocal( + RENDER_PARAMS.focalY, + params.get(RENDER_PARAMS.focalY), + ); + if (typeof focalY !== "number") return focalY; + photoFocus = { focalX, focalY }; + } + + try { + validateContent(template, values); + } catch (cause) { + if (cause instanceof ContentError) + return fail("invalid-content", cause.message, cause.fieldIds); + throw cause; + } + + return { request: { template, format, values, photoFocus } }; +} diff --git a/src/components/marketing/GraphicRender/utils/useRenderedGraphic.test.ts b/src/components/marketing/GraphicRender/utils/useRenderedGraphic.test.ts new file mode 100644 index 0000000..de99d5a --- /dev/null +++ b/src/components/marketing/GraphicRender/utils/useRenderedGraphic.test.ts @@ -0,0 +1,239 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import type { PhotoState } from "../../ExportLab/ExportLab.types"; +import { loadGraphicFonts } from "../../ExportLab/utils/loadGraphicFonts"; +import { rasterizeGraphic } from "../../ExportLab/utils/rasterizeGraphic"; +import type { RenderRequest } from "../GraphicRender.types"; +import { parseRenderParams } from "./parseRenderParams"; +import { useRenderedGraphic } from "./useRenderedGraphic"; + +vi.mock("../../ExportLab/utils/loadGraphicFonts", () => ({ + loadGraphicFonts: vi.fn(), +})); +vi.mock("../../ExportLab/utils/rasterizeGraphic", () => ({ + rasterizeGraphic: vi.fn(), +})); + +const requestFor = (query: string) => + parseRenderParams(new URLSearchParams(query)).request as RenderRequest; + +const plain = requestFor("template=standard&format=landscape&title=Tytuł"); +const withPhoto = requestFor( + "template=standard&format=square&photo=1&focalX=10&focalY=90", +); +const NO_PHOTO: PhotoState = { photo: null, isLoading: false, error: "" }; +const PHOTO: PhotoState = { + photo: { url: "blob:photo", focalX: 50, focalY: 50 }, + isLoading: false, + error: "", +}; + +describe("useRenderedGraphic", () => { + let urlCount = 0; + + beforeEach(() => { + urlCount = 0; + vi.mocked(loadGraphicFonts).mockResolvedValue(); + vi.mocked(rasterizeGraphic).mockResolvedValue({ + blob: new Blob(["png"]), + hasOverflow: false, + }); + URL.createObjectURL = vi.fn(() => `blob:${++urlCount}`); + URL.revokeObjectURL = vi.fn(); + }); + + describe("when there is no request yet", () => { + it("stays loading without rendering", () => { + const { result } = renderHook(() => useRenderedGraphic(null, NO_PHOTO)); + expect(result.current).toEqual({ status: "loading" }); + expect(rasterizeGraphic).not.toHaveBeenCalled(); + }); + }); + + describe("when the request needs no photo", () => { + it("rasterizes the format once the fonts are loaded", async () => { + const { result } = renderHook(() => useRenderedGraphic(plain, NO_PHOTO)); + expect(result.current).toEqual({ status: "loading" }); + await waitFor(() => + expect(result.current).toEqual({ + status: "ready", + url: "blob:1", + hasOverflow: false, + }), + ); + expect(rasterizeGraphic).toHaveBeenCalledWith( + plain.template, + { values: plain.values, photo: null }, + plain.format, + ); + }); + + it("ignores a photo that was not announced in the URL", async () => { + const { result } = renderHook(() => useRenderedGraphic(plain, PHOTO)); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(rasterizeGraphic).toHaveBeenCalledWith( + plain.template, + { values: plain.values, photo: null }, + plain.format, + ); + }); + + it("reports text that does not fit", async () => { + vi.mocked(rasterizeGraphic).mockResolvedValue({ + blob: new Blob(["png"]), + hasOverflow: true, + }); + const { result } = renderHook(() => useRenderedGraphic(plain, NO_PHOTO)); + await waitFor(() => + expect(result.current).toMatchObject({ + status: "ready", + hasOverflow: true, + }), + ); + }); + }); + + describe("when the request announces a photo", () => { + it("waits for the file, then renders it at the requested focal point", async () => { + const { result, rerender } = renderHook( + ({ photo }) => useRenderedGraphic(withPhoto, photo), + { initialProps: { photo: NO_PHOTO } }, + ); + expect(result.current).toEqual({ status: "awaiting-photo" }); + expect(rasterizeGraphic).not.toHaveBeenCalled(); + + rerender({ photo: { ...NO_PHOTO, isLoading: true } }); + expect(result.current).toEqual({ status: "loading" }); + expect(rasterizeGraphic).not.toHaveBeenCalled(); + + rerender({ photo: PHOTO }); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(rasterizeGraphic).toHaveBeenCalledWith( + withPhoto.template, + { + values: withPhoto.values, + photo: { url: "blob:photo", focalX: 10, focalY: 90 }, + }, + withPhoto.format, + ); + }); + + it("fails when the file cannot be used", () => { + const { result } = renderHook(() => + useRenderedGraphic(withPhoto, { + ...NO_PHOTO, + error: "Wybierz JPG, PNG lub WebP.", + }), + ); + expect(result.current).toEqual({ + status: "error", + error: { + code: "invalid-photo", + message: "Wybierz JPG, PNG lub WebP.", + fieldIds: [], + }, + }); + }); + }); + + describe("when rendering fails", () => { + it("keeps the cause's message", async () => { + vi.mocked(loadGraphicFonts).mockRejectedValue(new Error("Brak fontów")); + const { result } = renderHook(() => useRenderedGraphic(plain, NO_PHOTO)); + await waitFor(() => + expect(result.current).toEqual({ + status: "error", + error: { + code: "render-failed", + message: "Brak fontów", + fieldIds: [], + }, + }), + ); + }); + + it("shows a generic message for non-Error values", async () => { + vi.mocked(rasterizeGraphic).mockRejectedValue("boom"); + const { result } = renderHook(() => useRenderedGraphic(plain, NO_PHOTO)); + await waitFor(() => + expect(result.current).toMatchObject({ + error: { message: "Nie udało się przygotować grafiki." }, + }), + ); + }); + }); + + describe("when the request changes while rendering", () => { + it("drops the stale result and revokes the replaced URL", async () => { + const { result, rerender } = renderHook( + ({ request }) => useRenderedGraphic(request, NO_PHOTO), + { initialProps: { request: plain } }, + ); + await waitFor(() => expect(result.current.status).toBe("ready")); + rerender({ request: { ...plain } }); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:1"); + await waitFor(() => + expect(result.current).toMatchObject({ url: "blob:2" }), + ); + }); + + it("ignores a late success of the stale render", async () => { + let finishStale = (_: { blob: Blob; hasOverflow: boolean }) => {}; + vi.mocked(rasterizeGraphic).mockReturnValueOnce( + new Promise((resolve) => { + finishStale = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ request }) => useRenderedGraphic(request, NO_PHOTO), + { initialProps: { request: plain } }, + ); + await waitFor(() => expect(rasterizeGraphic).toHaveBeenCalledTimes(1)); + rerender({ request: { ...plain } }); + await waitFor(() => expect(result.current.status).toBe("ready")); + finishStale({ blob: new Blob(["stale"]), hasOverflow: true }); + await Promise.resolve(); + expect(result.current).toEqual({ + status: "ready", + url: "blob:1", + hasOverflow: false, + }); + }); + + it("ignores a late failure of the stale render", async () => { + let failStale = (_: unknown) => {}; + vi.mocked(rasterizeGraphic).mockReturnValueOnce( + new Promise((_, reject) => { + failStale = reject; + }), + ); + const { result, rerender } = renderHook( + ({ request }) => useRenderedGraphic(request, NO_PHOTO), + { initialProps: { request: plain } }, + ); + await waitFor(() => expect(rasterizeGraphic).toHaveBeenCalledTimes(1)); + rerender({ request: { ...plain } }); + await waitFor(() => expect(result.current.status).toBe("ready")); + failStale(new Error("stale")); + await Promise.resolve(); + expect(result.current.status).toBe("ready"); + }); + + it("skips rasterizing when replaced while the fonts load", async () => { + let finishFonts = () => {}; + vi.mocked(loadGraphicFonts).mockReturnValueOnce( + new Promise((resolve) => { + finishFonts = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ request }) => useRenderedGraphic(request, NO_PHOTO), + { initialProps: { request: plain } }, + ); + rerender({ request: { ...plain } }); + await waitFor(() => expect(result.current.status).toBe("ready")); + finishFonts(); + await Promise.resolve(); + expect(rasterizeGraphic).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/marketing/GraphicRender/utils/useRenderedGraphic.ts b/src/components/marketing/GraphicRender/utils/useRenderedGraphic.ts new file mode 100644 index 0000000..45e0e55 --- /dev/null +++ b/src/components/marketing/GraphicRender/utils/useRenderedGraphic.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import type { PhotoState } from "../../ExportLab/ExportLab.types"; +import { loadGraphicFonts } from "../../ExportLab/utils/loadGraphicFonts"; +import { rasterizeGraphic } from "../../ExportLab/utils/rasterizeGraphic"; +import type { RenderRequest, RenderState } from "../GraphicRender.types"; + +const LOADING_STATE: RenderState = { status: "loading" }; +const AWAITING_PHOTO_STATE: RenderState = { status: "awaiting-photo" }; + +/** + * Rasterizes the requested graphic with the editor's own pipeline, so the PNG + * is the one the editor would export for the same values. + */ +export function useRenderedGraphic( + request: RenderRequest | null, + { photo, isLoading, error }: PhotoState, +): RenderState { + const [state, setState] = useState(LOADING_STATE); + + useEffect(() => { + if (!request) return; + const { template, format, values, photoFocus } = request; + if (photoFocus && error) { + setState({ + status: "error", + error: { code: "invalid-photo", message: error, fieldIds: [] }, + }); + return; + } + if (photoFocus && (isLoading || !photo)) { + setState(isLoading ? LOADING_STATE : AWAITING_PHOTO_STATE); + return; + } + + let isCancelled = false; + let url: string | undefined; + setState(LOADING_STATE); + + async function prepare() { + try { + await loadGraphicFonts(); + if (isCancelled) return; + const { blob, hasOverflow } = await rasterizeGraphic( + template, + { + values, + photo: photoFocus && photo ? { ...photo, ...photoFocus } : null, + }, + format, + ); + if (isCancelled) return; + url = URL.createObjectURL(blob); + setState({ status: "ready", url, hasOverflow }); + } catch (cause) { + if (isCancelled) return; + setState({ + status: "error", + error: { + code: "render-failed", + message: + cause instanceof Error + ? cause.message + : "Nie udało się przygotować grafiki.", + fieldIds: [], + }, + }); + } + } + + void prepare(); + return () => { + isCancelled = true; + if (url) URL.revokeObjectURL(url); + }; + }, [request, photo, isLoading, error]); + + return state; +} diff --git a/src/pages/marketing_.render.tsx b/src/pages/marketing_.render.tsx new file mode 100644 index 0000000..f1e8d6e --- /dev/null +++ b/src/pages/marketing_.render.tsx @@ -0,0 +1,5 @@ +import type { RouteHandle } from "../types/console"; + +export { default } from "../components/marketing/GraphicRender/GraphicRender"; + +export const handle: RouteHandle = { isBare: true }; diff --git a/src/root.tsx b/src/root.tsx index 7062eb4..77b96f1 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -1,14 +1,19 @@ import { config } from "@fortawesome/fontawesome-svg-core"; -import { Links, Outlet, Scripts } from "react-router"; +import { Links, Outlet, Scripts, useMatches } from "react-router"; import ConsoleShell from "./components/console/ConsoleShell/ConsoleShell"; import "@fortawesome/fontawesome-svg-core/styles.css"; import favicon from "./assets/images/avatar.png"; import "./index.css"; +import type { RouteHandle } from "./types/console"; // Styles are imported above; runtime injection would flash unstyled icons on SSR. config.autoAddCss = false; export default function App() { + const isBare = useMatches().some( + (match) => (match.handle as RouteHandle | undefined)?.isBare, + ); + return ( @@ -22,9 +27,13 @@ export default function App() { - + {isBare ? ( - + ) : ( + + + + )} diff --git a/src/types/console.ts b/src/types/console.ts index 5585d44..85e91c3 100644 --- a/src/types/console.ts +++ b/src/types/console.ts @@ -14,3 +14,8 @@ export interface ConsoleModule { /** Selectable subviews nested under this module's navigation entry. */ subModules?: readonly ConsoleSubModule[]; } + +export interface RouteHandle { + /** Shows the page without the Console shell, e.g. a graphic rendered for automation. */ + isBare?: boolean; +}