From 9efae681ac86569b3f3c108c7a8bdb429fab54db Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 25 Sep 2026 17:39:20 +0000 Subject: [PATCH] Restliche Review-Befunde und die fehlenden Gestaltungsmuster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KORREKTHEIT export() wendet jetzt ValidationUtils.validateDownload() an, dieselbe Pruefung wie der Downloadknopf. Ohne sie ging ein Bild mit leerem weissen Logobalken raus: Die App zeichnet den Balken unbedingt, nur der Ortsname fehlt — es sieht nichts kaputt aus, bis es jemand veroeffentlicht. Das hat vier bestehende Tests umgeworfen, die ohne Logo exportierten, und dabei gezeigt, dass render({template, text}) sonst unbenutzbar geworden waere. Der Logo-Zustand wird deshalb aus dem Spec abgeleitet: Wer keins nennt, will keins. setBackground() wartet auf ein ANDERES Bild statt darauf, dass ueberhaupt eines existiert. contentImage ist ein Einwegriegel — replaceCanvas() und resetWizard() verwerfen das Canvas, setzen ihn aber nie zurueck. Nach einem Vorlagenwechsel war er noch wahr, ein Austausch kehrte also sofort zurueck, bevor das neue Foto geladen war. addText() setzt jetzt ALLE Bedienelemente, nicht nur die genannten. Der Handler liest alle; ein weggelassenes Feld erbte damit den vorigen Aufruf. Ein gelber rechtsbuendiger Text faerbte jeden folgenden mit ein. GESTALTUNGSMUSTER sendBackwards() und sendToBack(). Fabric bringt beides mit, die App verdrahtet keines, die Oberflaeche hat nur einen Nach-vorne-Knopf. Ohne das laesst sich eine Ueberlappung nie wieder aufloesen. Beide halten Grundflaeche und Hintergrundfoto ganz hinten. addCredit() fuer Bildnachweis und KI-Kennzeichnung. Beides steht als Pflicht in der llms.txt, musste aber als gewoehnlicher Text nachgebaut werden — und der wird auf 80 % der Bildbreite skaliert. Ein Nachweis in Schlagzeilengroesse ist kein Nachweis. Feste kleine Groesse relativ zur kurzen Kante, innerhalb der Schutzzone. duplicate() fuer wiederholte Motive, etwa Raeder fuer eine Radboerse. Klont das Fabric-Objekt statt das Bild erneut zu laden; der Test zaehlt die fetch-Aufrufe mit und belegt: vier Icons, ein Ladevorgang. Dazu die Emoji-Einordnung aus dem Review in der llms.txt: Emoji kommen durch, aber aus der Systemschrift statt der Hausschrift, und zusammengesetzte zerfallen in ihre Bestandteile. Ein Symbol als PNG oder SVG ist verlaesslich, ein Emoji nicht. VERSION auf 13. 132 Jest, 137 e2e gruen. Claude-Session: https://claude.ai/code/session_01Qtib9MPRZATQU3j9w7ymDk --- CLAUDE.md | 17 +++++ e2e/design-patterns.spec.js | 145 ++++++++++++++++++++++++++++++++++++ e2e/review-findings.spec.js | 88 ++++++++++++++++++++++ llms.txt | 78 ++++++++++++++++++- resources/js/api.js | 139 ++++++++++++++++++++++++++++++++-- 5 files changed, 458 insertions(+), 9 deletions(-) create mode 100644 e2e/design-patterns.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index 7b13971..e689114 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/e2e/design-patterns.spec.js b/e2e/design-patterns.spec.js new file mode 100644 index 0000000..cd6b1ff --- /dev/null +++ b/e2e/design-patterns.spec.js @@ -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]); + }); +}); diff --git a/e2e/review-findings.spec.js b/e2e/review-findings.spec.js index 43ee698..8f2d346 100644 --- a/e2e/review-findings.spec.js +++ b/e2e/review-findings.spec.js @@ -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 }; @@ -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; diff --git a/llms.txt b/llms.txt index 3b3c649..47bea1a 100644 --- a/llms.txt +++ b/llms.txt @@ -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({ @@ -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 | @@ -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 @@ -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 diff --git a/resources/js/api.js b/resources/js/api.js index 19706ce..534fc00 100644 --- a/resources/js/api.js +++ b/resources/js/api.js @@ -31,7 +31,7 @@ * breakage surfaces in e2e/api.spec.js instead of in somebody else's script. */ const Bildgenerator = { - VERSION: 11, + VERSION: 13, /** The object the most recent add* call put on the canvas. */ _lastAdded: null, @@ -179,8 +179,18 @@ const Bildgenerator = { }; // Set before the image loads: positionBackgroundImage() reads it. CanvasUtils.setBackgroundFocus(focus(opts.focusX), focus(opts.focusY)); + + // Wait for a DIFFERENT image, not merely for one to exist. + // contentImage is a one-shot latch: replaceCanvas() and resetWizard() + // dispose the canvas without clearing it, so after a template change + // it is still truthy and a replacement would return immediately — + // before the new photo had loaded. + const previous = contentImage; processMeme({ url: url }); - await this._waitFor(() => !!window.contentImage, "background image to load"); + await this._waitFor( + () => !!contentImage && contentImage !== previous, + "background image to load" + ); await this._waitUntilQuiet(); return this; }, @@ -212,11 +222,16 @@ const Bildgenerator = { this._openSection("text-section"); - if (opts.color) jQuery("#text-color").val(opts.color); - if (opts.fontStyle) jQuery("#font-style-select").val(opts.fontStyle); - if (opts.align) jQuery(`input[name="align"]#${opts.align}`).prop("checked", true); - if (opts.lineHeight) jQuery("#line-height").val(opts.lineHeight); - if (typeof opts.shadow === "number") jQuery("#shadow-depth").val(opts.shadow); + // Set EVERY control, not only the ones the caller named. The handler + // reads all of them, so an omitted field would otherwise inherit + // whatever the last call left behind: one yellow right-aligned text + // and every later one is yellow and right-aligned too. The same spec + // has to produce the same text. + jQuery("#text-color").val(opts.color || this._optionValues("#text-color")[0]); + jQuery("#font-style-select").val(opts.fontStyle || AppConstants.FONTS.OPTIONS[0].id); + jQuery(`input[name="align"]#${opts.align || "center"}`).prop("checked", true); + jQuery("#line-height").val(opts.lineHeight || this._optionValues("#line-height")[0]); + jQuery("#shadow-depth").val(typeof opts.shadow === "number" ? opts.shadow : 0); await this._addTracking(function () { jQuery("#text").val(text); @@ -836,6 +851,98 @@ const Bildgenerator = { } }, + /** + * Move an element one step back, or all the way behind everything except + * the canvas surface and the background photo. + * + * Fabric has both; the app wires neither, and the UI has only a + * bring-to-front button. Without them a caller can never undo an overlap + * — every new element goes on top and stays there. + */ + async sendToBack(which) { + const target = this._resolveTarget(which !== undefined ? which : this.lastAdded()); + this._assertEditable(target, "sendToBack"); + canvas.sendToBack(target); + // Never behind the surface or the photo — that would hide it entirely. + if (contentRect) canvas.sendToBack(contentRect); + if (contentImage) canvas.sendToBack(contentImage); + canvas.renderAll(); + return this; + }, + + /** Move an element one step back in the stack. */ + async sendBackwards(which) { + const target = this._resolveTarget(which !== undefined ? which : this.lastAdded()); + this._assertEditable(target, "sendBackwards"); + canvas.sendBackwards(target); + if (contentRect) canvas.sendToBack(contentRect); + if (contentImage) canvas.sendToBack(contentImage); + canvas.renderAll(); + return this; + }, + + /** + * Add the photo credit or the AI disclosure as a small line inside the + * margin. Both are obligations rather than design choices, so they get a + * fixed relative size instead of the automatic 80 % fit — a credit as + * large as a headline is not a credit. + * + * @param {string} text + * @param {Object} [options] + * @param {string} [options.position='bottom-left'] from options().positions + * @param {number} [options.size=0.018] share of the short edge + */ + async addCredit(text, options) { + const opts = options || {}; + await this.addText(text, { color: "#FFFFFF", align: "left" }); + const credit = this.lastAdded(); + + const shortEdge = Math.min(canvas.width, canvas.height); + const wanted = shortEdge * (typeof opts.size === "number" ? opts.size : 0.018); + const current = credit.getScaledHeight(); + if (current) { + credit.scale((credit.scaleX || 1) * (wanted / current)).setCoords(); + credit._gatBaseScale = credit.scaleX; + } + await this.place(opts.position || "bottom-left"); + return this; + }, + + /** + * Repeat the most recently added element. Clones the fabric object rather + * than fetching and decoding the image again — a row of six bicycles for + * a bike-swap poster is one decode, not six. + * + * The clones are returned so the caller can place each one; the tracked + * element stays the original. + * + * @param {Object} [options] + * @param {number} [options.count=1] + * @returns {Promise} the new objects, in creation order + */ + async duplicate(options) { + const opts = options || {}; + const source = this.lastAdded(); + if (!source) throw new Error("duplicate() needs an element that was added first"); + this._assertEditable(source, "duplicate"); + + const count = typeof opts.count === "number" ? Math.floor(opts.count) : 1; + if (!(count > 0)) throw new Error("duplicate() needs a positive count"); + + const clones = []; + for (let i = 0; i < count; i++) { + const clone = await new Promise(function (resolve) { + source.clone(resolve); + }); + clone._gatBaseScale = source._gatBaseScale || source.scaleX || 1; + canvas.add(clone); + clones.push(clone); + } + CanvasUtils.bringLogoToFront(); + canvas.renderAll(); + return clones; + }, + _resolveTarget(which) { if (which && typeof which === "object") return which; if (typeof which === "number") { @@ -864,6 +971,16 @@ const Bildgenerator = { */ async export(options) { const opts = options || {}; + + // The same gate the download button applies (event-handlers.js). + // Without it an image rendered before a logo was chosen ships a blank + // white logo bar — the app draws the bar unconditionally and only the + // region name is missing, so nothing looks broken until someone + // publishes it. + const validation = ValidationUtils.validateDownload(); + if (!validation.isValid) { + throw new Error(validation.error); + } // The download button reads the DPI from the template // (event-handlers.js); a hardcoded 200 silently produced a different // resolution than the same template yields for a person. @@ -926,7 +1043,13 @@ const Bildgenerator = { throw new Error("render() needs at least { template }"); } await this.setTemplate(spec.template); - if (spec.logoEnabled === false) await this.setLogoEnabled(false); + // LogoState persists across calls, so a previous render({logoEnabled: + // false}) would silently drop the logo from the next one. Derive the + // state from the spec instead: a logo is wanted when one is named, or + // when logoEnabled says so explicitly. Naming neither means none — + // otherwise export() would refuse the render for a logo the caller + // never asked for. + await this.setLogoEnabled(spec.logoEnabled === true || !!spec.logo); if (spec.background) await this.setBackground(spec.background); if (spec.logo) await this.setLogo(spec.logo); // After the logo, so it survives addLogo() being called again.