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

### Two logo artworks

`LOGO.FILES` holds four files, not two. The `_blanko` artwork is pure white on
transparent: the counters of the G and the letters in the white bar are holes,
so the green canvas shows through them. That is how the logo is meant to read —
and it only works because the canvas IS green. Over a background photograph
those holes show the photo and the wordmark washes out.

The `_gruen` artwork has exactly those ENCLOSED holes filled with the brand
green; the outer transparency is untouched, so the logo still has no box around
it. Inside/outside is decided by a flood fill from the image border
(`scripts/make-green-logo.py`), and interior anti-aliased edges are blended
towards green rather than towards whatever lies behind.

`addLogo()` picks the artwork from `LogoState.isKnockoutEnabled()` — the same
switch that decides whether the region name is a knockout or a green fill, so
the two never disagree.

Note: the CD quick guide's own layout examples DO place the knockout logo on a
photograph. It works there because the photo behind it is dark and even. The
filled variant is for the cases the guide does not show.

### Logo region name: knockout or filled

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

/**
* The logo artwork comes in two versions.
*
* The "blanko" files are pure white on transparent: the counters of the G and
* the letters in the white bar are holes, so the green canvas shows through
* them. That is how the logo is meant to read — and it only works because the
* canvas IS green.
*
* Over a background photograph those holes show the photo, and the wordmark
* washes out. The "gruen" files have exactly those enclosed areas filled with
* the brand green, outer transparency untouched so there is still no box.
*/
test.describe('Logo artwork variants', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => typeof window.Bildgenerator !== 'undefined');
page.on('dialog', (dialog) => dialog.accept());
});

test('both artworks are served', async ({ page }) => {
const status = await page.evaluate(async () => {
const files = Object.values(AppConstants.LOGO.FILES);
const out = {};
for (const f of files) {
const r = await fetch(`resources/images/logos/${f}`);
out[f] = r.status;
}
return out;
});

for (const [file, code] of Object.entries(status)) {
expect(code, file).toBe(200);
}
expect(Object.keys(status)).toHaveLength(4);
});

test('the filled artwork is used exactly when the knockout is off', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
const sourceOf = () => {
const l = canvas.getObjects().filter((o) => o.type === 'image' && o.selectable === false).pop();
return l && l._element ? l._element.src : '';
};

await B.setTemplate('feed_post_45');
await B.setLogo('HERZOGENBURG');
await B.setLogoKnockout(true);
const withKnockout = sourceOf();
await B.setLogoKnockout(false);
const filled = sourceOf();
return { withKnockout, filled };
});

// The flattened logo is a data URL, so check what went INTO it by
// re-reading the constants rather than the src.
expect(result.withKnockout).not.toBe(result.filled);
});

test('the filled artwork has green exactly where the blanko one has holes', async ({ page }) => {
const result = await page.evaluate(async () => {
const load = (src) => new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const x = c.getContext('2d');
x.drawImage(img, 0, 0);
resolve({ data: x.getImageData(0, 0, img.width, img.height).data, w: img.width, h: img.height });
};
img.src = src;
});

const base = 'resources/images/logos/';
const blank = await load(base + AppConstants.LOGO.FILES.SHORT);
const filled = await load(base + AppConstants.LOGO.FILES.SHORT_FILLED);

let holesFilledGreen = 0;
let holesLeftOpen = 0;
let whiteChanged = 0;
let openAtBorder = 0;

for (let i = 0; i < blank.data.length; i += 4) {
const pixel = i / 4;
const x = pixel % blank.w;
const y = Math.floor(pixel / blank.w);
const onBorder = x < 2 || y < 2 || x > blank.w - 3 || y > blank.h - 3;

const wasHole = blank.data[i + 3] < 32;
const nowGreen = filled.data[i + 3] > 200 &&
Math.abs(filled.data[i] - 37) < 12 &&
Math.abs(filled.data[i + 1] - 118) < 12 &&
Math.abs(filled.data[i + 2] - 57) < 12;

if (wasHole && nowGreen) holesFilledGreen++;
if (wasHole && filled.data[i + 3] < 32) holesLeftOpen++;
if (onBorder && filled.data[i + 3] < 32) openAtBorder++;

// Fully opaque white is the artwork itself and must survive untouched.
// Partially transparent pixels are the anti-aliased edges; the
// enclosed ones are deliberately blended towards green instead of
// towards whatever lies behind, which is the whole point.
if (blank.data[i + 3] === 255 && blank.data[i] > 250) {
if (filled.data[i] < 250 || filled.data[i + 3] < 255) whiteChanged++;
}
}
return { holesFilledGreen, holesLeftOpen, whiteChanged, openAtBorder };
});

// The enclosed holes — counters of the G, letters in the bar — are green.
expect(result.holesFilledGreen).toBeGreaterThan(10000);
// The outer transparency survives, or the logo would sit in a green box.
expect(result.holesLeftOpen).toBeGreaterThan(result.holesFilledGreen);
expect(result.openAtBorder).toBeGreaterThan(0);
// And the white artwork itself is untouched.
expect(result.whiteChanged).toBe(0);
});
});
6 changes: 4 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -629,8 +629,10 @@ Logo liegt. Auf der einfarbig grünen Fläche ist das genau richtig.
Buchstaben, und der Ortsname wird unleserlich.

**Das erledigt der Generator selbst:** Sobald ein Hintergrundbild gesetzt
wird, füllt er den Ortsnamen mit demselben Grün, das sonst durchgeschienen
hätte. Das passiert genau einmal — wer danach bewusst wieder auf durchsichtig
wird, schaltet er auf die gefüllte Logo-Variante um. Dann sind sowohl der
Ortsname als auch „DIE GRÜNEN" und die Innenflächen des G grün gefüllt statt
durchsichtig — das Logo bringt sein Grün also selbst mit und bleibt über jedem
Foto lesbar. Das passiert genau einmal — wer danach bewusst wieder auf durchsichtig
stellt, wird von weiteren Bildern nicht überstimmt.

Steuern lässt es sich über `setLogoKnockout(true|false)` oder
Expand Down
Binary file added resources/images/logos/Logo-einzeilig_gruen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added resources/images/logos/Logo-zweizeilig_gruen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 12 additions & 1 deletion resources/js/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,20 @@ const AppConstants = {
BAR_OFFSET_FROM_TOP_LONG: 0.89, // Two-line text starts slightly higher to fit within the taller bar
// Automatic positioning configuration
BORDERLESS_MARGIN_PERCENT: 0.02, // For borderless templates: logo bottom margin as percentage of canvas height (2%)
// Two artworks of the same logo. The "blanko" files are pure white on
// transparent: over the green canvas the enclosed areas — the counters
// of the G, the letters in the white bar — show the green through, and
// that is how the logo is meant to read. Over a background photograph
// they show the PHOTO, and the wordmark becomes unreadable.
//
// The "gruen" files have exactly those enclosed areas filled with the
// brand green; the outer transparency is untouched, so the logo still
// has no box around it. Generated by scripts/make-green-logo.py.
FILES: {
LONG: "Logo-zweizeilig_blanko.png",
SHORT: "Logo-einzeilig_blanko.png"
SHORT: "Logo-einzeilig_blanko.png",
LONG_FILLED: "Logo-zweizeilig_gruen.png",
SHORT_FILLED: "Logo-einzeilig_gruen.png"
}
}
};
Expand Down
13 changes: 11 additions & 2 deletions resources/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,16 @@ function addLogo() {

let logoFilename, textScaleTo, isLongLogo;

// Same decision as for the region name: with the knockout the artwork
// relies on green lying behind it, which only holds on the plain canvas.
// Over a photo the filled variant is the one that reads.
const useFilled = !LogoState.isKnockoutEnabled();

// Check if text needs breaking (either has % or is too long)
if (logoText.includes("%") || logoText.length > AppConstants.LOGO.MAX_TEXT_LENGTH) {
logoFilename = AppConstants.LOGO.FILES.LONG;
logoFilename = useFilled
? AppConstants.LOGO.FILES.LONG_FILLED
: AppConstants.LOGO.FILES.LONG;
isLongLogo = true;

if (logoText.includes("%")) {
Expand All @@ -175,7 +182,9 @@ function addLogo() {

textScaleTo = AppConstants.LOGO.TEXT_SCALE_LONG;
} else {
logoFilename = AppConstants.LOGO.FILES.SHORT;
logoFilename = useFilled
? AppConstants.LOGO.FILES.SHORT_FILLED
: AppConstants.LOGO.FILES.SHORT;
isLongLogo = false;
textScaleTo = AppConstants.LOGO.TEXT_SCALE_SHORT;
}
Expand Down
75 changes: 75 additions & 0 deletions scripts/make-green-logo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Erzeugt die grün gefüllte Logo-Variante aus der weiß/transparenten Vorlage.

Die Vorlagen sind rein weiß auf transparent: Über der grünen Fläche wirkt das
richtig, über einem Foto zeigen die Innenflächen des G und die Buchstaben im
Balken aber das Foto. Diese Variante füllt genau diese EINGESCHLOSSENEN
Flächen mit dem Markengrün — die äußere Transparenz bleibt, sonst bekäme das
Logo einen grünen Kasten.

Aussen und innen werden über eine Flutfüllung vom Bildrand unterschieden:
Was vom Rand aus erreichbar ist, ist aussen.
"""
from collections import deque
from PIL import Image
import sys

GREEN = (37, 118, 57, 255) # BACKGROUND_SECONDARY / #257639
ALPHA_THRESHOLD = 32


def fill_enclosed(path_in, path_out):
im = Image.open(path_in).convert("RGBA")
w, h = im.size
px = im.load()

outside = bytearray(w * h)
queue = deque()

def consider(x, y):
if 0 <= x < w and 0 <= y < h and not outside[y * w + x] and px[x, y][3] < ALPHA_THRESHOLD:
outside[y * w + x] = 1
queue.append((x, y))

for x in range(w):
consider(x, 0)
consider(x, h - 1)
for y in range(h):
consider(0, y)
consider(w - 1, y)

while queue:
x, y = queue.popleft()
consider(x + 1, y)
consider(x - 1, y)
consider(x, y + 1)
consider(x, y - 1)

filled = 0
for y in range(h):
row = y * w
for x in range(w):
r, g, b, a = px[x, y]
if a < ALPHA_THRESHOLD and not outside[row + x]:
px[x, y] = GREEN
filled += 1
elif a < 255 and not outside[row + x]:
# Weiche Kante über Grün statt über dem Hintergrund mischen.
f = a / 255
px[x, y] = (
round(r * f + GREEN[0] * (1 - f)),
round(g * f + GREEN[1] * (1 - f)),
round(b * f + GREEN[2] * (1 - f)),
255,
)
filled += 1

im.save(path_out)
return filled, w * h


if __name__ == "__main__":
for name in ("Logo-einzeilig", "Logo-zweizeilig"):
src = f"resources/images/logos/{name}_blanko.png"
dst = f"resources/images/logos/{name}_gruen.png"
filled, total = fill_enclosed(src, dst)
print(f"{dst}: {filled} von {total} Pixeln gefüllt ({filled * 100 // total} %)")
Loading