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
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,27 @@ 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.

### Background photographs and the green panel

`CanvasUtils.backgroundFocus` decides which part of an overflowing background
photo survives the cover-crop. 0.5/0.5 is the centre and the long-standing
behaviour; `setBackgroundFocus(x, y)` moves it. The facade exposes this as
`setBackground(url, { focusX, focusY })` — a portrait in a landscape template
loses the head at the default, and a caller that cannot drag the image had no
way to fix that.

`#add-panel` adds a green rectangle. It exists because the brand rule "type
always in combination with green" is otherwise unachievable over a photograph
— there was no way to put a green surface under a headline. It is a real UI
button, not a facade-only path, so the coverage test stays meaningful and
people get the capability too.

`addText(text, { panel: true })` binds a panel to that text via
`text._gatPanel`, and `_refitPanel()` re-sizes and re-centres it after every
move, scale, rotate or update. Without that binding a later `place()` moves
the text and leaves the surface behind — the text ends up half off its own
panel, which is worse than no panel at all.

### Logo region name: knockout or filled

The region name on the white logo bar is cut OUT of the bar
Expand Down
195 changes: 195 additions & 0 deletions e2e/background-and-panel.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { test, expect } from '@playwright/test';

/**
* Background photographs as a design element, and the green surface the brand
* rule requires under type.
*
* Both were gaps the external review named: the photo could only ever be
* centred, and there was no way at all to put type on green over a photo —
* which made the brand's central typography rule unachievable with a
* background image.
*/

// Built in the page rather than pasted as base64, so the test cannot fail on
// a hand-assembled PNG. Wide and short: in a tall frame the width overflows,
// which is exactly the case the focus point exists for.
const WIDE_IMAGE = `(() => {
const c = document.createElement('canvas');
c.width = 400; c.height = 100;
const x = c.getContext('2d');
x.fillStyle = '#ff0000'; x.fillRect(0, 0, 200, 100);
x.fillStyle = '#0000ff'; x.fillRect(200, 0, 200, 100);
return c.toDataURL();
})()`;

const TINY_PNG =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC';

test.describe('Background focus and green panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => typeof window.Bildgenerator !== 'undefined');
page.on('dialog', (dialog) => dialog.accept());
});

test('the focus point decides which part of the photo survives the crop', async ({ page }) => {
const result = await page.evaluate(async (source) => {
const B = window.Bildgenerator;
const png = eval(source);
const positions = {};
for (const [name, focusX] of [['left', 0], ['centre', 0.5], ['right', 1]]) {
await B.setTemplate('story'); // tall frame, wide photo: x overflows
await B.setBackground(png, { focusX });
positions[name] = Math.round(contentImage.left);
}
return positions;
}, WIDE_IMAGE);

// focusX 0 pins the left edge of the photo to the frame; 1 pushes it left
// so the right edge shows. Centre sits between the two.
expect(result.left).toBeGreaterThan(result.centre);
expect(result.centre).toBeGreaterThan(result.right);
});

test('the default is still centred, exactly as before', async ({ page }) => {
const result = await page.evaluate(async (source) => {
const B = window.Bildgenerator;
const png = eval(source);
await B.setTemplate('story');
await B.setBackground(png);
const withoutOption = Math.round(contentImage.left);
await B.setBackground(png, { focusX: 0.5 });
return { withoutOption, explicitCentre: Math.round(contentImage.left) };
}, WIDE_IMAGE);

expect(result.withoutOption).toBe(result.explicitCentre);
});

test('a focus value outside 0..1 is refused', async ({ page }) => {
const message = await page.evaluate(async (png) => {
try {
await window.Bildgenerator.setBackground(png, { focusY: 1.5 });
return null;
} catch (e) {
return e.message;
}
}, TINY_PNG);

expect(message).toContain('0 to 1');
});

test('the green panel exists as a button and in options()', async ({ page }) => {
const result = await page.evaluate(() => ({
button: !!document.getElementById('add-panel'),
shapes: window.Bildgenerator.options().shapes,
}));

// The facade drives the same control a person does — no second path.
expect(result.button).toBe(true);
expect(result.shapes).toContain('panel');
});

test('addPanel puts a green surface on the canvas behind the text', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.setLogoEnabled(false);
await B.addText('Rückenwind');
await B.addPanel({ width: 0.9, height: 0.3 });

const objects = B.objects();
const panel = objects.filter((o) => o.type === 'rect').pop();
const text = objects.find((o) => o.type === 'text');
return { panelIndex: panel.index, textIndex: text.index, colour: panel.color };
});

expect(result.colour.toLowerCase()).toBe('#257639');
// Panel behind the text, not over it.
expect(result.panelIndex).toBeLessThan(result.textIndex);
});

test('addText({ panel: true }) sizes the surface to the text and keeps text on top', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('artikel_23');
await B.setLogoEnabled(false);
await B.addText('Windpark kommt', { panel: true });

const objects = B.objects();
const panel = objects.filter((o) => o.type === 'rect').pop();
const text = objects.find((o) => o.type === 'text');
return {
panel, text,
trackedIsText: B.lastAdded().type,
};
});

// Surface larger than the type on both axes, and centred on it.
expect(result.panel.width).toBeGreaterThan(result.text.width);
expect(result.panel.height).toBeGreaterThan(result.text.height);
expect(result.panel.left + result.panel.width / 2)
.toBeCloseTo(result.text.left + result.text.width / 2, 0);
expect(result.panel.index).toBeLessThan(result.text.index);

// The text remains the tracked element, so a following place() moves the
// text — which is what a caller writing this would expect.
expect(result.trackedIsText).toBe('text');
});

test('the green surface follows its text when the text is moved or scaled', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('artikel_23');
await B.setLogoEnabled(false);
await B.addText('Windpark kommt', { panel: true });

const centre = (o) => ({ x: o.left + o.width / 2, y: o.top + o.height / 2 });
const read = () => {
const objects = B.objects();
return {
panel: centre(objects.find((o) => o.role === 'panel')),
text: centre(objects.find((o) => o.type === 'text')),
panelSize: objects.find((o) => o.role === 'panel'),
textSize: objects.find((o) => o.type === 'text'),
};
};

await B.place('bottom');
const moved = read();
await B.resize(0.5);
const scaled = read();
return { moved, scaled };
});

// Moving the text used to leave the surface behind, so the text ended up
// half off its own panel — worse than having no panel at all.
expect(result.moved.panel.x).toBeCloseTo(result.moved.text.x, 0);
expect(result.moved.panel.y).toBeCloseTo(result.moved.text.y, 0);

// And scaling must shrink the surface with the type, not leave a slab.
expect(result.scaled.panel.x).toBeCloseTo(result.scaled.text.x, 0);
expect(result.scaled.panelSize.width).toBeLessThan(result.moved.panelSize.width);
expect(result.scaled.panelSize.width).toBeGreaterThan(result.scaled.textSize.width);
});

test('type over a photo can be put on green in one call', async ({ page }) => {
const objects = await page.evaluate(async (png) => {
const B = window.Bildgenerator;
await B.render({
template: 'artikel_23',
background: png,
logo: 'HERZOGENBURG',
texts: [{ text: 'Windpark kommt', panel: true, position: 'center' }],
dpi: 72,
});
return B.objects().map((o) => o.role);
}, TINY_PNG);

// background photo, green panel, text, logo — the brand rule satisfied
// with a photograph, which was impossible before.
expect(objects).toContain('background');
expect(objects).toContain('logo');
expect(objects).toContain('panel');
expect(objects).toContain('text');
});
});
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,9 @@ <h4>Weitere Elemente</h4>
<button class="app-element-button" id="add-cross">
<i class="fas fa-xmark"></i>Wahlkreuz
</button>
<button class="app-element-button" id="add-panel">
<i class="fas fa-square"></i>Grüne Fläche
</button>
<button class="app-element-button" id="show-qr-section">
<i class="fas fa-qrcode"></i>QR Code
</button>
Expand Down
67 changes: 61 additions & 6 deletions 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 === 10`.
Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 11`.

```js
const bild = await window.Bildgenerator.render({
Expand Down Expand Up @@ -120,9 +120,10 @@ Objekt zurück, sind also verkettbar.
| `setLogo(name)` | Organisationslogo setzen |
| `setLogoEnabled(false)` | Organisationslogo entfernen |
| `setLogoKnockout(bool)` | Ortsname im Logo durchsichtig oder grün gefüllt |
| `setBackground(url)` | Hintergrundbild setzen |
| `setBackground(url, opts)` | Hintergrundbild setzen, mit Bildausschnitt |
| `addText(text, opts)` | Text hinzufügen, beliebig oft — Optionen siehe unten |
| `addShape(kind)` | `'pinkCircle'` oder `'cross'` hinzufügen |
| `addShape(kind)` | `'pinkCircle'`, `'cross'` oder `'panel'` hinzufügen |
| `addPanel(opts)` | grüne Fläche für Text |
| `addImage(url)` | weiteres Bild auf das Motiv legen |
| `addQRCode({text, color})` | QR-Code auf das Bild setzen |
| `place(position)` | das zuletzt hinzugefügte Element positionieren |
Expand Down Expand Up @@ -337,9 +338,27 @@ Text wird ausschließlich auf einer grünen Farbfläche platziert. Auf einem
Hintergrundfoto ist Text nur zulässig, wenn er auf einer grünen Fläche liegt —
oder wenn die abgebildete Person eindeutig vor Grün steht.

Das erzwingt der Generator **nicht**. Ohne Hintergrundbild ist die Fläche
ohnehin grün, dann passt es immer. Sobald du ein Foto setzt, liegt es an dir:
Entweder du lässt den Text weg, oder du sorgst dafür, dass er auf Grün sitzt.
Ohne Hintergrundbild ist die Fläche ohnehin grün, dann passt es immer. Sobald
du ein Foto setzt, gehört unter jeden Text eine grüne Fläche:

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

await B.setTemplate('artikel_23');
await B.setBackground(foto);
await B.addText('Windpark kommt', { panel: true });
await B.place('bottom');
```

`panel: true` legt die Fläche an, passt sie an den Text an und hält sie
darunter — auch wenn du den Text danach verschiebst oder skalierst. Der Text
bleibt dabei das aktive Element, `place()` und `resize()` meinen also weiter
ihn.

Brauchst du die Fläche für sich, etwa als Farbblock ohne Text, gibt es
`addPanel({ width, height })` mit Anteilen der nutzbaren Fläche.

Weißer Text quer über ein Foto ist CD-widrig, auch wenn er lesbar ist.

Hintergründe sind entweder Foto **oder** Farbfläche — nicht beides vermischt.
Expand Down Expand Up @@ -544,6 +563,42 @@ Steuern lässt es sich über `setLogoKnockout(true|false)` oder
In der Oberfläche ist es die Checkbox „Ortsname im Logo durchsichtig" in
Schritt 1.

## Hintergrundbilder

Das Foto wird formatfüllend skaliert. Eine Achse steht dabei fast immer über
und wird beschnitten — ein Hochformat in einer Querformat-Vorlage verliert
links und rechts, ein Querformat in einer hohen Vorlage oben und unten.

**Welcher Teil überlebt, entscheidest du.** Ohne Angabe wird mittig
beschnitten, und das ist oft falsch: Bei einem Porträt in einer breiten
Vorlage fehlt dann der Kopf.

```js
const B = window.Bildgenerator;
const foto = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC';
await B.setTemplate('event');

await B.setBackground(foto, { focusY: 0 }); // oberer Bildrand, Gesichter
await B.setBackground(foto, { focusX: 1 }); // rechter Bildrand
await B.setBackground(foto); // mittig, wie bisher
```

`focusX` und `focusY` laufen von `0` (links beziehungsweise oben) über `0.5`
(Mitte, Vorgabe) bis `1` (rechts beziehungsweise unten). Werte außerhalb
werden abgelehnt.

Faustregeln:

- **Porträt oder Person:** `focusY: 0` bis `0.3`, damit der Kopf im Bild
bleibt.
- **Landschaft mit Himmel:** `focusY: 0.6` bis `1`, damit nicht der halbe
Himmel das Bild füllt.
- **Gruppenbild:** mittig lassen und lieber ein Foto wählen, dessen
Seitenverhältnis zur Vorlage passt — ein starker Beschnitt schneidet sonst
immer jemanden ab.

Steht Text auf dem Foto, gehört eine grüne Fläche darunter — siehe oben.

## Formate

| Name | Canvas | Wofür |
Expand Down
Loading
Loading