diff --git a/CLAUDE.md b/CLAUDE.md index e689114..cf9283f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/e2e/logo-variant.spec.js b/e2e/logo-variant.spec.js new file mode 100644 index 0000000..7ee745d --- /dev/null +++ b/e2e/logo-variant.spec.js @@ -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); + }); +}); diff --git a/llms.txt b/llms.txt index 47bea1a..de64ee3 100644 --- a/llms.txt +++ b/llms.txt @@ -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 diff --git a/resources/images/logos/Logo-einzeilig_gruen.png b/resources/images/logos/Logo-einzeilig_gruen.png new file mode 100644 index 0000000..6f1ee88 Binary files /dev/null and b/resources/images/logos/Logo-einzeilig_gruen.png differ diff --git a/resources/images/logos/Logo-zweizeilig_gruen.png b/resources/images/logos/Logo-zweizeilig_gruen.png new file mode 100644 index 0000000..87b1fd2 Binary files /dev/null and b/resources/images/logos/Logo-zweizeilig_gruen.png differ diff --git a/resources/js/constants.js b/resources/js/constants.js index fa30f5d..7e4ef63 100644 --- a/resources/js/constants.js +++ b/resources/js/constants.js @@ -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" } } }; diff --git a/resources/js/main.js b/resources/js/main.js index 7951ec0..adba57d 100644 --- a/resources/js/main.js +++ b/resources/js/main.js @@ -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("%")) { @@ -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; } diff --git a/scripts/make-green-logo.py b/scripts/make-green-logo.py new file mode 100644 index 0000000..3ba5677 --- /dev/null +++ b/scripts/make-green-logo.py @@ -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} %)")