From 2f142fdb9ac923f63450ae25595172bdb72d0737 Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 25 Sep 2026 13:01:16 +0000 Subject: [PATCH 1/3] Text, Groesse und freie Positionierung dokumentieren und ermoeglichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine fremde Session kam mit der llms.txt nicht weiter: mehrere Texte, andere Farben, Groesse, links- und rechtsbuendig — alles unklar. Zu Recht. Teils war es undokumentiert, teils gab es die Faehigkeit gar nicht. Undokumentiert war, dass addText() ueberhaupt Optionen hat. Die Tabelle sagte nur "Schlagzeile hinzufuegen"; Farbe, Schriftwahl, Ausrichtung, Zeilenabstand und Schatten standen nirgends. Ebensowenig, dass addText() beliebig oft aufgerufen werden kann. Gefehlt hat: - Mehrere Texte in render() ueber `texts`, jeder mit eigener Farbe, Groesse und Position. Ohne das landet jeder weitere Text zentriert auf dem vorigen. - resize(faktor). Die Oberflaeche hat einen Groessenregler auf dem ausgewaehlten Objekt, die Fassade legte ihn nur nicht frei. Ein Schriftgroessenfeld gibt es nicht — Text wird auf 80 % der Bildbreite skaliert, und das war die einzige Groesse, die ein Aufrufer bekam. - Freie Koordinaten in place({x, y}) neben den neun Feldern. Die Schutzzone gilt weiter; Werte ausserhalb 0 bis 1 werden abgelehnt statt stillschweigend begrenzt, sonst waere die Zusage in der llms.txt unwahr. Der neue Abschnitt "Text im Detail" erklaert ausserdem die Verwechslung, die am meisten Zeit kostet: `align` richtet die Zeilen INNERHALB des Blocks aus, `place()` bewegt den Block auf dem Bild. Linksbuendig am linken Rand braucht beides. Dazu eine Liste dessen, was NICHT geht — keine freie Schriftgroesse in Punkt, keine freien Farben, kein Textrahmen, keine Drehung — mit der Anweisung, das zu sagen statt etwas Aehnliches zu liefern. e2e/llms-examples.spec.js fuehrt jeden Codeblock der llms.txt gegen die echte Seite aus. Ein Beispiel, das nicht laeuft, ist schlechter als keines: Das Modell folgt ihm, bekommt einen Fehler, den es nicht deuten kann, und improvisiert — genau der Fehlermodus, gegen den die Schnittstelle gebaut ist. Der Test hat sofort ein Fragment ohne Kontext gefunden, das jetzt vollstaendig ist. VERSION auf 4. Claude-Session: https://claude.ai/code/session_01Qtib9MPRZATQU3j9w7ymDk --- CLAUDE.md | 5 ++ e2e/api.spec.js | 108 ++++++++++++++++++++++++++++++++++- e2e/llms-examples.spec.js | 46 +++++++++++++++ llms.txt | 115 ++++++++++++++++++++++++++++++++++++-- resources/js/api.js | 106 +++++++++++++++++++++++++++++------ 5 files changed, 357 insertions(+), 23 deletions(-) create mode 100644 e2e/llms-examples.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index 2a2014a..5567b20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,11 @@ controls and handlers a person does; it is NOT a second rendering path. Colours, fonts and logo placement need no documenting; "Typografie steht immer in Kombination mit Grün" and the protective margin M = 0.06 x short edge do. +- `e2e/llms-examples.spec.js` executes every ```js block in `llms.txt` against + the page. A worked example that does not work is worse than none: the model + follows it, hits an error it cannot interpret, and improvises — the failure + this interface exists to prevent. Keep examples complete and runnable, not + fragments. - `lastAdded()` exists because `canvas.getObjects()` ends with the organisation LOGO, not with the element just added — `addLogo()` and the QR handler both call `bringLogoToFront()`. Anything that operates on "the last object" moves diff --git a/e2e/api.spec.js b/e2e/api.spec.js index df51770..f7398f7 100644 --- a/e2e/api.spec.js +++ b/e2e/api.spec.js @@ -34,7 +34,7 @@ test.describe('Bildgenerator facade', () => { version: window.Bildgenerator.VERSION, })); - expect(version).toBe(3); + expect(version).toBe(4); expect(templates).toContain('artikel_23'); expect(templates).toContain('feed_post_45'); expect(logos).toContain('HERZOGENBURG'); @@ -336,6 +336,23 @@ test.describe('Bildgenerator facade', () => { expect(result.after).toEqual(result.before); }); + test('rejects free coordinates outside the canvas', async ({ page }) => { + const message = await page.evaluate(async () => { + await window.Bildgenerator.setTemplate('feed_post_45'); + await window.Bildgenerator.addShape('cross'); + try { + await window.Bildgenerator.place({ x: 1.5, y: 0 }); + return null; + } catch (error) { + return error.message; + } + }); + + // llms.txt promises these are rejected; silently clamping would make the + // document a lie and put elements somewhere the caller did not ask for. + expect(message).toContain('between 0 and 1'); + }); + test('rejects an unknown position and lists the valid ones', async ({ page }) => { const message = await page.evaluate(async () => { await window.Bildgenerator.setTemplate('feed_post_45'); @@ -352,6 +369,95 @@ test.describe('Bildgenerator facade', () => { expect(message).toContain('top-left'); }); + // -------------------------------------------------- several texts, sizing + + test('renders several texts with their own colour, size and position', async ({ page }) => { + const result = await page.evaluate(async () => { + await window.Bildgenerator.render({ + template: 'artikel_23', + logoEnabled: false, + texts: [ + { text: 'Windpark', color: '#FFED00', position: 'top' }, + { text: 'kommt', color: '#FFFFFF', size: 0.5, position: 'bottom' }, + ], + dpi: 72, + }); + const texts = canvas.getObjects().filter((o) => o.type === 'text'); + return texts.map((t) => ({ + text: t.text, + fill: t.fill, + scale: Math.round(t.scaleX * 100) / 100, + top: Math.round(t.top), + })); + }); + + expect(result).toHaveLength(2); + expect(result[0].fill).toBe('#FFED00'); + expect(result[1].fill).toBe('#FFFFFF'); + // Two texts must not end up stacked on the same spot. + expect(result[0].top).not.toBe(result[1].top); + expect(result[1].top).toBeGreaterThan(result[0].top); + }); + + test('resize() changes an element without moving it off the canvas', async ({ page }) => { + const result = await page.evaluate(async () => { + await window.Bildgenerator.setTemplate('feed_post_45'); + await window.Bildgenerator.addText('Test'); + const before = window.Bildgenerator.lastAdded().getScaledWidth(); + await window.Bildgenerator.resize(0.4); + const after = window.Bildgenerator.lastAdded().getScaledWidth(); + return { before, after }; + }); + + expect(result.after).toBeLessThan(result.before); + }); + + test('place() accepts free coordinates and still honours the margin', async ({ page }) => { + const result = await page.evaluate(async () => { + await window.Bildgenerator.setTemplate('feed_post_45'); + await window.Bildgenerator.setLogoEnabled(false); + await window.Bildgenerator.addShape('pinkCircle'); + await window.Bildgenerator.place({ x: 0, y: 0 }); + const atOrigin = { ...window.Bildgenerator.lastAdded() }; + const o = window.Bildgenerator.lastAdded(); + const topLeft = { left: o.left, top: o.top }; + + await window.Bildgenerator.place({ x: 1, y: 1 }); + const bottomRight = { + right: o.left + o.getScaledWidth(), + bottom: o.top + o.getScaledHeight(), + }; + return { + topLeft, + bottomRight, + margin: window.Bildgenerator.protectiveMargin(), + canvas: { w: canvas.width, h: canvas.height }, + }; + }); + + expect(result.topLeft.left).toBeCloseTo(result.margin, 1); + expect(result.topLeft.top).toBeCloseTo(result.margin, 1); + expect(result.bottomRight.right).toBeCloseTo(result.canvas.w - result.margin, 1); + expect(result.bottomRight.bottom).toBeCloseTo(result.canvas.h - result.margin, 1); + }); + + test('text alignment and block placement are different things', async ({ page }) => { + const result = await page.evaluate(async () => { + await window.Bildgenerator.setTemplate('feed_post_45'); + await window.Bildgenerator.setLogoEnabled(false); + await window.Bildgenerator.addText('Zeile eins\nZeile zwei', { align: 'right' }); + const text = window.Bildgenerator.lastAdded(); + const centred = text.left; + await window.Bildgenerator.place('left'); + return { textAlign: text.textAlign, centred, placed: text.left }; + }); + + // align='right' sets the lines inside the block; the block itself only + // moves through place(). Confusing the two is the most common mistake. + expect(result.textAlign).toBe('right'); + expect(result.placed).toBeLessThan(result.centred); + }); + // --------------------------------------------------------------- failures test('rejects an unknown template by name and lists the valid ones', async ({ page }) => { diff --git a/e2e/llms-examples.spec.js b/e2e/llms-examples.spec.js new file mode 100644 index 0000000..afa233d --- /dev/null +++ b/e2e/llms-examples.spec.js @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const llms = readFileSync(join(process.cwd(), 'llms.txt'), 'utf8'); + +/** + * Runs the code examples from llms.txt against the real page. + * + * A worked example that does not work is worse than none: the model follows it, + * hits an error it cannot interpret, and improvises — the exact failure this + * interface exists to prevent. The examples are the part of the document most + * likely to be copied verbatim, so they are the part that must be executable. + */ +test.describe('llms.txt examples actually run', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => typeof window.Bildgenerator !== 'undefined'); + page.on('dialog', (dialog) => dialog.accept()); + }); + + // Every ```js block in the document. + const examples = [...llms.matchAll(/```js\n([\s\S]*?)```/g)].map((m) => m[1]); + + test('the document contains runnable examples', () => { + expect(examples.length).toBeGreaterThanOrEqual(3); + }); + + for (const [index, code] of examples.entries()) { + const firstLine = code.trim().split('\n')[0].slice(0, 60); + test(`example ${index + 1} runs: ${firstLine}…`, async ({ page }) => { + const error = await page.evaluate(async (source) => { + try { + // The examples are written as top-level await against the page. + const run = new Function(`return (async () => { ${source} })()`); + await run(); + return null; + } catch (e) { + return e.message; + } + }, code); + + expect(error, `example ${index + 1} failed`).toBeNull(); + }); + } +}); diff --git a/llms.txt b/llms.txt index fbcdc7c..467239c 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 === 3`. +Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 4`. ```js const bild = await window.Bildgenerator.render({ @@ -85,10 +85,12 @@ Bildgenerator.options() // alle übrigen Auswahlmöglichkeiten | `background` | Hintergrundbild als `data:`-URL oder absolute URL | | `logo` | Organisation aus `logos()`, z. B. `'HERZOGENBURG'` | | `logoEnabled` | `false`, um ganz ohne Organisationslogo zu arbeiten | -| `text` | Schlagzeile | +| `text` | eine einzelne Schlagzeile | +| `texts` | **mehrere Texte** mit eigener Farbe, Größe und Position — siehe unten | | `textColor` | aus `options().textColors` | | `fontStyle` | aus `options().fontStyles` — `'standard'` oder `'accent'` (kursive Akzentschrift) | -| `align` | `left` \| `center` \| `right` | +| `align` | `left` \| `center` \| `right` — Ausrichtung **innerhalb** des Textblocks | +| `textSize` | Skalierung des Textblocks, `1` = wie eingefügt | | `lineHeight` | aus `options().lineHeights` | | `shadow` | Schattentiefe als Zahl | | `textPosition` | aus `options().positions` | @@ -116,11 +118,12 @@ Objekt zurück, sind also verkettbar. | `setLogo(name)` | Organisationslogo setzen | | `setLogoEnabled(false)` | Organisationslogo entfernen | | `setBackground(url)` | Hintergrundbild setzen | -| `addText(text, opts)` | Schlagzeile hinzufügen | +| `addText(text, opts)` | Text hinzufügen, beliebig oft — Optionen siehe unten | | `addShape(kind)` | `'pinkCircle'` oder `'cross'` hinzufügen | | `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 | +| `resize(faktor)` | das zuletzt hinzugefügte Element vergrößern oder verkleinern | | `clipToCircle(size)` | das zuletzt hinzugefügte Objekt kreisrund beschneiden | | `lastAdded()` | das zuletzt hinzugefügte Objekt | | `protectiveMargin()` | die Schutzzone M in Pixeln | @@ -131,6 +134,110 @@ Objekt zurück, sind also verkettbar. nichts hinzu — er beschneidet. Der Kreis, den man hinzufügen kann, ist `addShape('pinkCircle')`. +## Text im Detail + +Der häufigste Stolperstein. Text ist das einzige Element mit vielen Optionen, +und zwei davon werden regelmäßig verwechselt. + +### Alle Optionen von `addText(text, optionen)` + +| Option | Werte | Bedeutung | +|---|---|---| +| `color` | aus `options().textColors` | `'#FFFFFF'` weiß, `'#FFED00'` gelb, `'#000000'` schwarz | +| `fontStyle` | aus `options().fontStyles` | `'standard'` oder `'accent'` (kursiv, nur für Zitate und Hervorhebungen) | +| `align` | `left`, `center`, `right` | Ausrichtung **innerhalb** des Blocks | +| `lineHeight` | aus `options().lineHeights` | `'1.0'`, `'1.15'`, `'1.35'` | +| `shadow` | Zahl | Schattentiefe, `0` = kein Schatten | + +Zeilenumbrüche setzt du mit `\n` im Text selbst. + +### `align` ist nicht Positionierung + +Das ist die Verwechslung, die am meisten Zeit kostet: + +- **`align`** bestimmt, wie die Zeilen *innerhalb* des Textblocks stehen. Bei + einzeiligem Text ist es wirkungslos. +- **`place()`** bewegt den Block *auf dem Bild*. + +Linksbündig am linken Bildrand heißt also beides: `addText(t, { align: 'left' })` +**und** `place('left')`. + +### Größe + +Es gibt kein Schriftgrößenfeld. Ein neuer Text wird automatisch auf 80 % der +Bildbreite skaliert — je mehr Wörter, desto kleiner wird er. + +`resize(faktor)` ändert das nachträglich: `1` ist die eingefügte Größe, `0.5` +die Hälfte, `2` das Doppelte. Der Regler in der Oberfläche erlaubt 0,1 bis 2; +darüber hinaus wird es unbrauchbar. + +Für eine kleine Subline unter einer großen Headline ist etwa `0.4` bis `0.5` +ein brauchbarer Ausgangswert. + +### Mehrere Texte + +`addText()` kann beliebig oft aufgerufen werden. **Jeder neue Text landet +zentriert** — ohne `place()` liegt der zweite auf dem ersten. + +In `render()` geht das über `texts`: + +```js +await window.Bildgenerator.render({ + template: 'artikel_23', + logo: 'HERZOGENBURG', + texts: [ + { text: 'Windpark\nkommt', + color: '#FFED00', position: 'top' }, + { text: 'Gemeinderat beschließt Standort', + color: '#FFFFFF', size: 0.35, position: 'center' }, + ], +}); +``` + +Einzeln geht dasselbe so: + +```js +const B = window.Bildgenerator; +await B.setTemplate('artikel_23'); +await B.setLogo('HERZOGENBURG'); + +await B.addText('Windpark\nkommt', { color: '#FFED00', align: 'left' }); +await B.place('top-left'); + +await B.addText('Gemeinderat beschließt Standort', { color: '#FFFFFF' }); +await B.resize(0.35); +await B.place({ x: 0, y: 0.45 }); + +const bild = await B.export(); +``` + +### Freie Positionierung + +`place()` nimmt neben den neun Feldern auch Koordinaten: + +```js +const B = window.Bildgenerator; +await B.setTemplate('artikel_23'); +await B.addText('Frei platziert'); +await B.resize(0.4); +await B.place({ x: 0, y: 0.45 }); // linker Rand, knapp oberhalb der Mitte +``` + +`x` und `y` laufen von `0` (links beziehungsweise oben) bis `1` (rechts +beziehungsweise unten), jeweils innerhalb der Schutzzone. `{ x: 0.5, y: 0.5 }` +ist die Mitte. Werte außerhalb von 0 bis 1 werden nicht akzeptiert — die +Schutzzone gilt immer. + +### Was nicht geht + +- **Keine freie Schriftgröße in Punkt** — nur `resize()` relativ zur + eingefügten Größe. +- **Keine freien Farben** — nur die drei aus `options().textColors`. +- **Kein Textrahmen und keine Hintergrundfläche hinter dem Text.** +- **Keine Drehung.** + +Wenn eine Anforderung das braucht, sag es, statt etwas Ähnliches zu liefern. + ## QR-Code-Generator Das zweite Werkzeug der Seite erzeugt einen QR-Code für sich, ohne Bild diff --git a/resources/js/api.js b/resources/js/api.js index 66ea3aa..fd809f8 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: 3, + VERSION: 4, /** The object the most recent add* call put on the canvas. */ _lastAdded: null, @@ -153,7 +153,9 @@ const Bildgenerator = { * @param {Object} [options] * @param {string} [options.color] value from options().textColors * @param {string} [options.fontStyle] id from options().fontStyles - * @param {string} [options.align] left | center | right + * @param {string} [options.align] left | center | right — how the lines + * sit inside the text block. To move + * the block itself, use place(). * @param {string} [options.lineHeight] value from options().lineHeights * @param {number} [options.shadow] shadow depth */ @@ -287,17 +289,42 @@ const Bildgenerator = { throw new Error("place() needs an element that was added first"); } + const margin = this.protectiveMargin(); + const width = target.getScaledWidth(); + const height = target.getScaledHeight(); + + // Free positioning: { x, y } as fractions of the canvas, 0 = left/top + // edge of the usable area, 1 = right/bottom. Still clamped to the + // protective margin, because that is a brand rule and not a default. + if (position && typeof position === "object") { + const inRange = function (v) { + return v === undefined || (typeof v === "number" && v >= 0 && v <= 1); + }; + if (!inRange(position.x) || !inRange(position.y)) { + throw new Error( + "place({ x, y }) takes fractions between 0 and 1; the " + + "protective margin is not optional" + ); + } + const usableWidth = canvas.width - 2 * margin - width; + const usableHeight = canvas.height - 2 * margin - height; + target.set({ + left: margin + Math.max(0, usableWidth) * (position.x || 0), + top: margin + Math.max(0, usableHeight) * (position.y || 0), + }); + target.setCoords(); + canvas.renderAll(); + return this; + } + const valid = this.options().positions; if (!valid.includes(position)) { throw new Error( - `Unknown position "${position}". Available: ${valid.join(", ")}` + `Unknown position "${position}". Available: ${valid.join(", ")}, ` + + `or { x, y } with values between 0 and 1` ); } - const margin = this.protectiveMargin(); - const width = target.getScaledWidth(); - const height = target.getScaledHeight(); - // The logo is placed by the app at the bottom; keep clear of it. let bottomLimit = canvas.height - margin; const placedLogo = this._logoObject(); @@ -323,6 +350,30 @@ const Bildgenerator = { return this; }, + /** + * Scale an element. The wizard exposes this as a slider on the selected + * object; there is no font-size field, so this is how text gets bigger or + * smaller too. + * + * A new text object is scaled to fit 80 % of the canvas width, so 1 is + * "as added". The UI slider allows 0.1 to 2. + * + * @param {number} factor absolute scale, not a multiplier + * @param {Object} [options] + * @param {fabric.Object} [options.target] default: the last element added + */ + async resize(factor, options) { + const opts = options || {}; + const target = opts.target || this.lastAdded(); + if (!target) throw new Error("resize() needs an element that was added first"); + if (typeof factor !== "number" || factor <= 0) { + throw new Error("resize() needs a positive number"); + } + target.scale(factor).setCoords(); + canvas.renderAll(); + return this; + }, + /** * The brand's protective margin in canvas pixels: M = 0.06 x short edge. * No element may fall inside it. Exposed so callers can reason about @@ -412,12 +463,17 @@ const Bildgenerator = { * @param {string} [spec.background] data: URL or absolute URL * @param {string} [spec.logo] from logos() * @param {boolean} [spec.logoEnabled] false to leave the logo off - * @param {string} [spec.text] headline + * @param {string} [spec.text] a single headline + * @param {Array} [spec.texts] several texts, each + * { text, color, fontStyle, align, + * lineHeight, shadow, size, position } * @param {string} [spec.textColor] from options().textColors * @param {string} [spec.fontStyle] from options().fontStyles - * @param {string} [spec.align] left | center | right + * @param {string} [spec.align] left | center | right — alignment + * WITHIN the text block, not on the canvas * @param {string} [spec.lineHeight] from options().lineHeights * @param {number} [spec.shadow] shadow depth + * @param {number} [spec.textSize] scale, 1 = as added * @param {string} [spec.textPosition] from options().positions * @param {Array} [spec.shapes] names from options().shapes, or * { kind, position } objects @@ -453,15 +509,29 @@ const Bildgenerator = { await this.addQRCode(spec.qr); if (spec.qr.position) await this.place(spec.qr.position); } - if (spec.text) { - await this.addText(spec.text, { - color: spec.textColor, - fontStyle: spec.fontStyle, - align: spec.align, - lineHeight: spec.lineHeight, - shadow: spec.shadow, - }); - if (spec.textPosition) await this.place(spec.textPosition); + // One headline via `text`, or several via `texts`. Each entry carries + // its own colour, size and position — a second text would otherwise + // land centred on top of the first. + const texts = spec.texts + ? spec.texts.slice() + : (spec.text + ? [{ + text: spec.text, + color: spec.textColor, + fontStyle: spec.fontStyle, + align: spec.align, + lineHeight: spec.lineHeight, + shadow: spec.shadow, + position: spec.textPosition, + size: spec.textSize, + }] + : []); + + for (const entry of texts) { + const item = typeof entry === "string" ? { text: entry } : entry; + await this.addText(item.text, item); + if (typeof item.size === "number") await this.resize(item.size); + if (item.position) await this.place(item.position); } return this.export({ format: spec.format, From ef524aee93fd9aade80800e725270f5d4dd5dc61 Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 25 Sep 2026 13:33:30 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Drehen=20ergaenzen=20=E2=80=94=20die=20Beha?= =?UTF-8?q?uptung,=20es=20ginge=20nicht,=20war=20falsch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die llms.txt fuehrte "Keine Drehung" unter dem auf, was nicht geht. Das war ungeprueft. relativeScalingControlsOnly() setzt in canvas-utils.js:61 ausdruecklich mtr: true — der Drehgriff ist auf Text, Bildern, QR-Codes und Formen aktiv, und beim Ziehen rastet er bei 0, 90, 180 und 270 Grad mit zwei Grad Toleranz ein. Eine falsche Angabe unter "geht nicht" ist besonders schaedlich: Sie bringt ein Modell dazu, eine Anforderung abzulehnen, die das Werkzeug erfuellen kann. Neu: rotate(grad), dazu textRotate und rotate in den Element-Eintraegen von render(). Der Winkel ist absolut, nicht relativ; negative Werte werden normalisiert, -90 wird zu 270. Programmatisch wird exakt gesetzt, ohne Einrasten — das steht so in der llms.txt, damit sich niemand auf Verhalten verlaesst, das nur beim Ziehen mit der Maus greift. Dazu die Einordnung, dass gedrehte Headlines im Erscheinungsbild nicht vorgesehen sind, beim Stoerer und beim Wahlkreuz eine leichte Schraege dagegen ueblich ist. Ohne den Satz wuerde die neue Faehigkeit vermutlich ueberstrapaziert. Die verbleibende "geht nicht"-Liste: keine Schriftgroesse in Punkt, keine freien Farben, kein Textrahmen und keine Hintergrundflaeche hinter dem Text. VERSION auf 5. Claude-Session: https://claude.ai/code/session_01Qtib9MPRZATQU3j9w7ymDk --- e2e/api.spec.js | 38 +++++++++++++++++++++++++++++++++++++- llms.txt | 33 +++++++++++++++++++++++++++------ resources/js/api.js | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/e2e/api.spec.js b/e2e/api.spec.js index f7398f7..03b368a 100644 --- a/e2e/api.spec.js +++ b/e2e/api.spec.js @@ -34,7 +34,7 @@ test.describe('Bildgenerator facade', () => { version: window.Bildgenerator.VERSION, })); - expect(version).toBe(4); + expect(version).toBe(5); expect(templates).toContain('artikel_23'); expect(templates).toContain('feed_post_45'); expect(logos).toContain('HERZOGENBURG'); @@ -458,6 +458,42 @@ test.describe('Bildgenerator facade', () => { expect(result.placed).toBeLessThan(result.centred); }); + test('rotate() turns an element and normalises the angle', async ({ page }) => { + const result = await page.evaluate(async () => { + await window.Bildgenerator.setTemplate('feed_post_45'); + await window.Bildgenerator.addShape('cross'); + await window.Bildgenerator.rotate(15); + const after = window.Bildgenerator.lastAdded().angle; + await window.Bildgenerator.rotate(-90); + const negative = window.Bildgenerator.lastAdded().angle; + return { after, negative }; + }); + + expect(result.after).toBe(15); + // Absolute, not relative — and a negative angle comes back in 0..360. + expect(result.negative).toBe(270); + }); + + test('render() rotates shapes and texts', async ({ page }) => { + const angles = await page.evaluate(async () => { + await window.Bildgenerator.render({ + template: 'feed_post_45', + logoEnabled: false, + texts: [{ text: 'Schräg', rotate: 350, position: 'center' }], + shapes: [{ kind: 'cross', rotate: 20, position: 'top-right' }], + dpi: 72, + }); + const objects = canvas.getObjects(); + return { + text: objects.find((o) => o.type === 'text').angle, + cross: objects.find((o) => o.type === 'image').angle, + }; + }); + + expect(angles.text).toBe(350); + expect(angles.cross).toBe(20); + }); + // --------------------------------------------------------------- failures test('rejects an unknown template by name and lists the valid ones', async ({ page }) => { diff --git a/llms.txt b/llms.txt index 467239c..b4860c9 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 === 4`. +Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 5`. ```js const bild = await window.Bildgenerator.render({ @@ -91,10 +91,11 @@ Bildgenerator.options() // alle übrigen Auswahlmöglichkeiten | `fontStyle` | aus `options().fontStyles` — `'standard'` oder `'accent'` (kursive Akzentschrift) | | `align` | `left` \| `center` \| `right` — Ausrichtung **innerhalb** des Textblocks | | `textSize` | Skalierung des Textblocks, `1` = wie eingefügt | +| `textRotate` | Drehung in Grad | | `lineHeight` | aus `options().lineHeights` | | `shadow` | Schattentiefe als Zahl | | `textPosition` | aus `options().positions` | -| `shapes` | Liste aus `options().shapes` — `'pinkCircle'`, `'cross'` (Wahlkreuz). Auch `{ kind, position }` | +| `shapes` | Liste aus `options().shapes` — `'pinkCircle'`, `'cross'` (Wahlkreuz). Auch `{ kind, position, size, rotate }` | | `images` | Liste weiterer Bilder. Auch `{ url, position }` | | `qr` | `{ text, color, position }` — QR-Code auf dem Bild | | `format` | `'png'` (Standard) oder `'jpeg'` | @@ -124,6 +125,7 @@ Objekt zurück, sind also verkettbar. | `addQRCode({text, color})` | QR-Code auf das Bild setzen | | `place(position)` | das zuletzt hinzugefügte Element positionieren | | `resize(faktor)` | das zuletzt hinzugefügte Element vergrößern oder verkleinern | +| `rotate(grad)` | das zuletzt hinzugefügte Element drehen | | `clipToCircle(size)` | das zuletzt hinzugefügte Objekt kreisrund beschneiden | | `lastAdded()` | das zuletzt hinzugefügte Objekt | | `protectiveMargin()` | die Schutzzone M in Pixeln | @@ -228,13 +230,32 @@ beziehungsweise unten), jeweils innerhalb der Schutzzone. `{ x: 0.5, y: 0.5 }` ist die Mitte. Werte außerhalb von 0 bis 1 werden nicht akzeptiert — die Schutzzone gilt immer. +### Drehen + +`rotate(grad)` dreht das zuletzt hinzugefügte Element im Uhrzeigersinn. Der +Wert ist absolut, nicht relativ. + +```js +const B = window.Bildgenerator; +await B.setTemplate('feed_post_45'); +await B.addShape('cross'); +await B.rotate(15); +await B.place('top-right'); +``` + +In der Oberfläche rastet der Drehgriff bei 0, 90, 180 und 270 Grad ein; über +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. + ### Was nicht geht -- **Keine freie Schriftgröße in Punkt** — nur `resize()` relativ zur - eingefügten Größe. -- **Keine freien Farben** — nur die drei aus `options().textColors`. +- **Keine freie Schriftgröße in Punkt** — Größe entsteht ausschließlich über + `resize()`, relativ zur eingefügten Größe. +- **Keine freien Farben** — nur die Werte aus `options().textColors` + beziehungsweise `options().qrColors`. - **Kein Textrahmen und keine Hintergrundfläche hinter dem Text.** -- **Keine Drehung.** Wenn eine Anforderung das braucht, sag es, statt etwas Ähnliches zu liefern. diff --git a/resources/js/api.js b/resources/js/api.js index fd809f8..a4d1968 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: 4, + VERSION: 5, /** The object the most recent add* call put on the canvas. */ _lastAdded: null, @@ -374,6 +374,29 @@ const Bildgenerator = { return this; }, + /** + * Rotate an element. The wizard offers this as the rotation handle on a + * selected object, and while dragging it snaps to 0, 90, 180 and 270 + * degrees within a 2 degree tolerance. Programmatic rotation sets the + * angle exactly and does not snap — pass the right angle. + * + * @param {number} degrees clockwise, absolute (not relative) + * @param {Object} [options] + * @param {fabric.Object} [options.target] default: the last element added + */ + async rotate(degrees, options) { + const opts = options || {}; + const target = opts.target || this.lastAdded(); + if (!target) throw new Error("rotate() needs an element that was added first"); + if (typeof degrees !== "number" || !isFinite(degrees)) { + throw new Error("rotate() needs a number of degrees"); + } + target.rotate(((degrees % 360) + 360) % 360); + target.setCoords(); + canvas.renderAll(); + return this; + }, + /** * The brand's protective margin in canvas pixels: M = 0.06 x short edge. * No element may fall inside it. Exposed so callers can reason about @@ -466,7 +489,8 @@ const Bildgenerator = { * @param {string} [spec.text] a single headline * @param {Array} [spec.texts] several texts, each * { text, color, fontStyle, align, - * lineHeight, shadow, size, position } + * lineHeight, shadow, size, rotate, + * position } * @param {string} [spec.textColor] from options().textColors * @param {string} [spec.fontStyle] from options().fontStyles * @param {string} [spec.align] left | center | right — alignment @@ -474,9 +498,10 @@ const Bildgenerator = { * @param {string} [spec.lineHeight] from options().lineHeights * @param {number} [spec.shadow] shadow depth * @param {number} [spec.textSize] scale, 1 = as added + * @param {number} [spec.textRotate] degrees clockwise * @param {string} [spec.textPosition] from options().positions * @param {Array} [spec.shapes] names from options().shapes, or - * { kind, position } objects + * { kind, position, size, rotate } objects * @param {Array} [spec.images] URLs, or { url, position } objects * @param {Object} [spec.qr] { text, color, position } * @param {string} [spec.format='png'] @@ -503,6 +528,8 @@ const Bildgenerator = { for (const shape of spec.shapes || []) { const kind = typeof shape === "string" ? shape : shape.kind; await this.addShape(kind); + if (typeof shape.rotate === "number") await this.rotate(shape.rotate); + if (typeof shape.size === "number") await this.resize(shape.size); if (shape.position) await this.place(shape.position); } if (spec.qr) { @@ -524,6 +551,7 @@ const Bildgenerator = { shadow: spec.shadow, position: spec.textPosition, size: spec.textSize, + rotate: spec.textRotate, }] : []); @@ -531,6 +559,7 @@ const Bildgenerator = { const item = typeof entry === "string" ? { text: entry } : entry; await this.addText(item.text, item); if (typeof item.size === "number") await this.resize(item.size); + if (typeof item.rotate === "number") await this.rotate(item.rotate); if (item.position) await this.place(item.position); } return this.export({ From 3ccff6de721c926c672b858afd348d25bbebf6b4 Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 25 Sep 2026 13:50:22 +0000 Subject: [PATCH 3/3] Vollstaendige Abdeckung: ansehen, auswaehlen, aendern, loeschen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Systematische Bestandsaufnahme aller Bedienelemente statt aus dem Gedaechtnis. Ergebnis: Die Fassade konnte hinzufuegen, aber nichts mit dem Hinzugefuegten anfangen. In der Oberflaeche klickt man ein Objekt an und bedient dann die Regler — ueber die Schnittstelle ging das gar nicht. Neu: - objects() listet alles auf dem Canvas mit Index, Typ, Rolle, Inhalt, Position, Groesse, Winkel und ob die Schutzzone eingehalten ist. - select(index) spricht ein Element an. - update({...}) aendert Text, Farbe, Ausrichtung, Zeilenabstand, Schatten und Schrift eines gesetzten Elements — samt initDimensions(), sonst behaelt Fabric die alten Textmasse und der neue Text laeuft ueber. - remove(index) loescht; Logo und Hintergrund sind geschuetzt, genau wie der Loeschknopf sie schuetzt. - bringToFront(index) ordnet um und laesst das Logo oben. - qrPayload(spec) baut die vier Inhaltstypen des QR-Assistenten. Bisher verlangten renderQRCode() und addQRCode() eine fertige Zeichenkette — ein Modell haette mailto:-Parameter und vCard-Records selbst zusammensetzen muessen, obwohl die Formatierungsregeln im Werkzeug stecken (QRFormatHelpers). e2e/api-coverage.spec.js haelt die Fassade an ihrem Versprechen: Es laeuft die Bedienelemente des Assistenten ab und prueft fuer jedes eine Entsprechung, vergleicht jeden