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
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ controls and handlers a person does; it is NOT a second rendering path.
`app.min.js` (from "Core utilities" through `qrcode-handlers.js`). A tag
after it is served separately and goes stale.

### Determinism and the export gate

- `addText()` sets EVERY control, not only the named ones. The handler reads
all of them, so an omitted field used to inherit the previous call: one
yellow right-aligned text and every later one was too. The same spec must
produce the same text.
- `render()` derives the logo state from the spec (`logo` named, or
`logoEnabled === true`) instead of leaving `LogoState` as the last call left
it.
- `export()` applies `ValidationUtils.validateDownload()`, the same gate the
download button uses. Without it an image rendered before a logo was chosen
shipped a blank white logo bar — the bar is drawn unconditionally and only
the region name is missing, so nothing looks broken until it is published.
- `setBackground()` waits for a DIFFERENT `contentImage`, not for one to
exist. `contentImage` is a one-shot latch that `replaceCanvas()` and
`resetWizard()` never clear.

### Background photographs and the green panel

`CanvasUtils.backgroundFocus` decides which part of an overflowing background
Expand Down
145 changes: 145 additions & 0 deletions e2e/design-patterns.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { test, expect } from '@playwright/test';

/**
* The design capabilities the external review found missing: stacking order
* backwards, the obligatory credit line, and repeating an element without
* fetching it again.
*/
test.describe('Design patterns', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => typeof window.Bildgenerator !== 'undefined');
page.on('dialog', (dialog) => dialog.accept());
});

test('an element can be sent back behind another', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.setLogoEnabled(false);
// Two elements of the same kind, so nothing else reorders them behind
// our back — the Störer handler brings text objects to the front by
// itself, which would hide what this test is about.
await B.addShape('cross');
const first = B.lastAdded();
await B.addShape('panel');
const second = B.lastAdded();

const indexOf = (o) => canvas.getObjects().indexOf(o);
const before = { first: indexOf(first), second: indexOf(second) };

await B.sendBackwards(); // moves `second` one step back
const stepped = { first: indexOf(first), second: indexOf(second) };

await B.sendToBack();
return {
before,
stepped,
secondIndex: indexOf(second),
surfaceIndex: canvas.getObjects().indexOf(contentRect),
};
});

// Added last, so it started on top.
expect(result.before.second).toBeGreaterThan(result.before.first);
// One step back puts it under the other.
expect(result.stepped.second).toBeLessThan(result.stepped.first);
// ...but never behind the canvas surface, which would hide it entirely.
expect(result.secondIndex).toBeGreaterThan(result.surfaceIndex);
});

test('sendToBack keeps the background photo behind everything', async ({ page }) => {
const result = await page.evaluate(async (png) => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.setLogoEnabled(false);
await B.setBackground(png);
await B.addShape('cross');
await B.sendToBack();

const objects = B.objects();
return {
cross: objects.find((o) => o.type === 'image' && o.role !== 'background').index,
background: objects.find((o) => o.role === 'background').index,
};
}, 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC');

expect(result.cross).toBeGreaterThan(result.background);
});

test('the credit line is small and sits inside the margin', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('artikel_23');
await B.setLogoEnabled(false);
await B.addText('Eine Schlagzeile');
const headline = B.objects().find((o) => o.type === 'text');

await B.addCredit('Symbolfoto — KI-generiert');
const credit = B.objects().filter((o) => o.type === 'text').pop();
return { headline, credit, shortEdge: Math.min(canvas.width, canvas.height) };
});

// A credit as large as a headline is not a credit. Fixed relative size,
// not the automatic 80 % fit every other text gets.
expect(result.credit.height).toBeLessThan(result.headline.height / 3);
expect(result.credit.height / result.shortEdge).toBeLessThan(0.05);
expect(result.credit.insideMargin).toBe(true);
});

test('duplicate repeats an element without fetching it again', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.setLogoEnabled(false);

// Count image requests so we can prove there is only one decode.
let fetches = 0;
const realFetch = window.fetch;
window.fetch = function (...args) { fetches++; return realFetch.apply(this, args); };

const c = document.createElement('canvas');
c.width = 40; c.height = 40;
const x = c.getContext('2d');
x.fillStyle = '#E6007E'; x.beginPath(); x.arc(20, 20, 18, 0, 7); x.fill();

await B.addImage(c.toDataURL());
const clones = await B.duplicate({ count: 5 });
window.fetch = realFetch;

const images = B.objects().filter((o) => o.type === 'image').length;
return { fetches, clones: clones.length, images };
});

// One fetch for the original, none for the five copies.
expect(result.fetches).toBe(1);
expect(result.clones).toBe(5);
expect(result.images).toBe(6);
});

test('a row of repeated icons can be laid out — the Radbörse case', async ({ page }) => {
const positions = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('artikel_23');
await B.setLogoEnabled(false);

const c = document.createElement('canvas');
c.width = 40; c.height = 40;
const x = c.getContext('2d');
x.fillStyle = '#FFED00'; x.fillRect(0, 0, 40, 40);

await B.addImage(c.toDataURL());
await B.resize(0.25);
const clones = await B.duplicate({ count: 3 });

const all = [B.lastAdded(), ...clones];
all.forEach((o, i) => {
o.set({ left: 100 + i * 200, top: 400 });
o.setCoords();
});
return all.map((o) => Math.round(o.left));
});

expect(positions).toEqual([100, 300, 500, 700]);
});
});
88 changes: 88 additions & 0 deletions e2e/review-findings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ test.describe('Review findings', () => {
const out = {};
for (const name of ['facebook_header', 'a6']) {
await B.setTemplate(name);
// No organisation wanted here — export() now refuses an image whose
// logo bar would be blank, exactly as the download button does.
await B.setLogoEnabled(false);
const exported = await B.export();
const template = window.TemplateConstants.getTemplate(name);
out[name] = { got: exported.dpi, expected: template.dpi };
Expand Down Expand Up @@ -141,6 +144,91 @@ test.describe('Review findings', () => {
expect(result).toEqual(['logo', 'logo', 'logo']);
});

test('export refuses an image whose logo bar would be blank', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
// Logo feature on, but no organisation picked — the app draws the white
// bar anyway and only the region name is missing.
LogoState.setLogoEnabled(true);
jQuery('#logo-selection').val('').trigger('change');

let refused = null;
try {
await B.export();
} catch (e) {
refused = e.message;
}

await B.setLogo('HERZOGENBURG');
const afterChoosing = await B.export({ dpi: 72 });
return { refused, ok: afterChoosing.dataURL.length > 0 };
});

// The download button applies this gate; the facade skipped it, so a
// blank logo bar shipped without anything looking broken.
expect(result.refused).toContain('Logo');
expect(result.ok).toBe(true);
});

test('setBackground waits for the NEW photo, not merely for any photo', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
const make = (colour) => {
const c = document.createElement('canvas');
c.width = 40; c.height = 40;
const x = c.getContext('2d');
x.fillStyle = colour; x.fillRect(0, 0, 40, 40);
return c.toDataURL();
};

await B.setTemplate('feed_post_45');
await B.setBackground(make('#ff0000'));
const first = contentImage;

// A template change disposes the canvas but never clears contentImage,
// so the old latch was still truthy here and a replacement returned
// before the new photo had loaded.
await B.setTemplate('story');
await B.setBackground(make('#0000ff'));
return { replaced: contentImage !== first, present: !!contentImage };
});

expect(result.present).toBe(true);
expect(result.replaced).toBe(true);
});

test('the same render spec produces the same text, whatever came before', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
const spec = { template: 'feed_post_45', text: 'Gleich', dpi: 72 };

// A first call that sets colour, alignment and shadow explicitly.
await B.render({
...spec,
text: 'Vorher',
textColor: '#FFED00',
align: 'right',
shadow: 20,
lineHeight: '1.35',
});

await B.render(spec);
const a = B.objects().find((o) => o.type === 'text');

await B.reset();
await B.render(spec);
const b = B.objects().find((o) => o.type === 'text');
return { a, b };
});

// addText() read every control, so omitted fields inherited the previous
// call: one yellow right-aligned text and every later one was too.
expect(result.a.color).toBe(result.b.color);
expect(result.a.width).toBe(result.b.width);
expect(result.a.height).toBe(result.b.height);
});

test('quiescence notices a change that leaves the object count untouched', async ({ page }) => {
const signatures = await page.evaluate(async () => {
const B = window.Bildgenerator;
Expand Down
78 changes: 77 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ weitere Freigabe.

Seite laden, warten bis `window.Bildgenerator` existiert, dann aufrufen.
Alle Methoden sind asynchron und liefern erst zurück, wenn das Canvas seine
Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 11`.
Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 13`.

```js
const bild = await window.Bildgenerator.render({
Expand Down Expand Up @@ -135,6 +135,10 @@ Objekt zurück, sind also verkettbar.
| `update(änderungen)` | ein gesetztes Element ändern |
| `remove(index)` | ein Element löschen |
| `bringToFront(index)` | ein Element nach vorne holen |
| `sendBackwards(index)` | ein Element einen Schritt nach hinten |
| `sendToBack(index)` | ein Element ganz nach hinten |
| `addCredit(text, opts)` | Bildnachweis oder KI-Kennzeichnung |
| `duplicate({count})` | das zuletzt hinzugefügte Element vervielfältigen |
| `qrPayload(spec)` | QR-Inhalt für `url`, `email`, `vcard` bauen |
| `clipToCircle(size)` | das zuletzt hinzugefügte Objekt kreisrund beschneiden |
| `lastAdded()` | das zuletzt hinzugefügte Objekt |
Expand Down Expand Up @@ -268,6 +272,24 @@ die Schnittstelle wird der Winkel exakt gesetzt, ohne Einrasten.
Sparsam einsetzen. Gedrehte Headlines sind im Erscheinungsbild nicht
vorgesehen — für Störer und Wahlkreuz ist eine leichte Schräge dagegen üblich.

### Dasselbe Spec ergibt dasselbe Bild

`render()` setzt alle Textoptionen, auch die, die du weglässt. Ein Aufruf mit
gelbem, rechtsbündigem Text färbt also nicht den nächsten mit ein. Ohne Angabe
gilt: weiß, zentriert, engster Zeilenabstand, kein Schatten, Standardschrift.

Ebenso das Logo: Nennst du keines und setzt auch `logoEnabled` nicht, wird
ohne Logo gerendert. Ein zuvor gesetztes Logo wirkt nicht nach.

### Export ohne Logo

Ist das Logo eingeschaltet, aber keine Organisation gewählt, **verweigert**
`export()` das Bild — genau wie der Downloadknopf in der Oberfläche. Der
Generator zeichnet den weißen Balken nämlich trotzdem, nur der Ortsname fehlt,
und das fällt erst nach der Veröffentlichung auf.

Wähle also entweder eine Organisation oder setz `logoEnabled: false`.

### Was nicht geht

- **Keine freie Schriftgröße in Punkt** — Größe entsteht ausschließlich über
Expand Down Expand Up @@ -543,6 +565,60 @@ setz einfach ein anderes Bild.

`bringToFront()` holt ein Element nach vorne, lässt das Logo aber oben.

## Wiederholte Elemente

Ein Motiv mehrfach aufs Bild zu setzen — etwa Fahrräder für eine Radbörse —
geht über `duplicate()`. Das Bild wird dabei **einmal** geladen und danach
geklont; sechs Aufrufe von `addImage()` wären sechs Ladevorgänge.

```js
const B = window.Bildgenerator;
const icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC';

await B.setTemplate('artikel_23');
await B.setLogoEnabled(false);
await B.addImage(icon);
await B.resize(0.25);

const kopien = await B.duplicate({ count: 3 });
// die Kopien liegen zunächst aufeinander — verteilen:
[B.lastAdded(), ...kopien].forEach((o, i) => {
o.set({ left: 120 + i * 220, top: 900 });
o.setCoords();
});
```

`duplicate()` liefert die neuen Objekte zurück; das verfolgte Element bleibt
das Original.

**Emoji sind dafür ungeeignet.** Sie kommen zwar durch, aber aus der
Systemschrift statt aus der Hausschrift, und zusammengesetzte Emoji zerfallen
in ihre Bestandteile — Flaggen, Familien, Hauttöne, Herz mit
Variationsselektor. Ein Symbol als transparentes PNG oder SVG über
`addImage()` ist verlässlich, ein Emoji nicht.

## Bildnachweis und Kennzeichnung

```js
const B = window.Bildgenerator;
await B.setTemplate('artikel_23');
await B.setLogoEnabled(false);
await B.addCredit('Symbolfoto — KI-generiert');
```

`addCredit()` setzt eine kleine Zeile innerhalb der Schutzzone, per Vorgabe
unten links. Anders als gewöhnlicher Text wird sie **nicht** auf 80 % der
Bildbreite skaliert, sondern auf einen festen kleinen Anteil der kurzen Kante
— ein Nachweis in Schlagzeilengröße ist kein Nachweis.

Über `{ position, size }` lässt sich beides ändern.

## Reihenfolge und Überlappung

Jedes neue Element liegt oben. `sendBackwards()` schiebt eines einen Schritt
zurück, `sendToBack()` ganz nach hinten — aber nie hinter die Grundfläche
oder das Hintergrundfoto, das würde es unsichtbar machen.

## Der Ortsname im Logo

Das Logo trägt den Ortsnamen auf einem weißen Balken. Per Vorgabe ist er
Expand Down
Loading
Loading