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.