Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<main>`:

| 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 `<img>` 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.
Expand Down
236 changes: 236 additions & 0 deletions e2e/marketing/render.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) =>
`./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<string>((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);
});
});
});
16 changes: 16 additions & 0 deletions src/components/marketing/GraphicRender/GraphicRender.constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading