From 368a758f49c5705e2dff0b262353a2e3dac49ca5 Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 25 Sep 2026 14:17:17 +0000 Subject: [PATCH 1/2] resize() relativ zur eingefuegten Groesse statt absolut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Nachfahren aller neuen Faehigkeiten gegen die Live-Seite kam ein Bild zurueck, dessen Text links und rechts hinauslief. objects() meldete korrekt insideMargin: false. Nachgemessen: Der Generator fuegt eine laengere Schlagzeile mit Skalierung 0,104 ein — 863 px auf 1080 px Bildbreite. resize(0.35) hat das als absoluten Wert gesetzt, also auf 2899 px. Fast das Dreifache der Bildbreite statt eines Drittels. Die llms.txt behauptete dazu "1 ist die eingefuegte Groesse". Das war falsch: Die eingefuegte Groesse ist 0,104. Wer der Anleitung folgte, machte Elemente um ein Vielfaches zu gross — und zwar in die Richtung, die die Zahl gerade nicht nahelegt. - resize(faktor) rechnet jetzt relativ zur eingefuegten Groesse. Die add*-Methoden merken sich beim Verfolgen, mit welcher Skalierung die App das Element angelegt hat. - place() klemmt auf den Bildrand, damit ein zu grosses Element nicht ganz aus dem Bild geschoben wird. objects() meldet weiterhin insideMargin: false, damit es auffaellt. - Zwei Regressionstests: einer prueft die relative Semantik direkt, der andere faehrt genau die Kombination, die das dreifach zu breite Bild erzeugt hat. Bemerkenswert: Alle 132 Jest- und 104 e2e-Tests waren gruen, die CI auch. Der Fehler steckte in der Bedeutung einer Zahl, nicht in einer Zusicherung, die ein Test geprueft haette. Gefunden hat ihn erst das Rendern eines echten Bildes nach dem Deploy — und angezeigt hat ihn objects(), also die Fassade selbst. VERSION auf 7. Claude-Session: https://claude.ai/code/session_01Qtib9MPRZATQU3j9w7ymDk --- e2e/api.spec.js | 42 ++++++++++++++++++++++++++++++++++-------- llms.txt | 20 +++++++++++++------- resources/js/api.js | 35 +++++++++++++++++++++++++++-------- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/e2e/api.spec.js b/e2e/api.spec.js index 64e000e..71e7781 100644 --- a/e2e/api.spec.js +++ b/e2e/api.spec.js @@ -401,17 +401,43 @@ test.describe('Bildgenerator facade', () => { expect(result[1].top).toBeGreaterThan(result[0].top); }); - test('resize() changes an element without moving it off the canvas', async ({ page }) => { + test('resize() is relative to the inserted size, not an absolute scale', 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 }; + const B = window.Bildgenerator; + await B.setTemplate('artikel_23'); + await B.addText('Gemeinderat beschliesst Standort'); + const inserted = B.lastAdded().getScaledWidth(); + + await B.resize(0.5); + const half = B.lastAdded().getScaledWidth(); + await B.resize(1); + const back = B.lastAdded().getScaledWidth(); + return { inserted, half, back, canvasWidth: canvas.width }; + }); + + // The app inserts a long headline at roughly scale 0.1. Treating the + // factor as an absolute fabric scale made resize(0.35) roughly three + // times WIDER than the canvas — the opposite of what the number says. + expect(result.half).toBeCloseTo(result.inserted / 2, 0); + expect(result.back).toBeCloseTo(result.inserted, 0); + expect(result.half).toBeLessThan(result.canvasWidth); + }); + + test('a text scaled down in render() stays inside the canvas', async ({ page }) => { + const text = await page.evaluate(async () => { + await window.Bildgenerator.render({ + template: 'artikel_23', + logoEnabled: false, + texts: [{ text: 'Gemeinderat beschliesst Standort', size: 0.35, position: 'center' }], + dpi: 72, + }); + return window.Bildgenerator.objects().find((o) => o.type === 'text'); }); - expect(result.after).toBeLessThan(result.before); + // This is the combination that produced a headline three canvases wide. + expect(text.width).toBeLessThan(1080); + expect(text.left).toBeGreaterThanOrEqual(0); + expect(text.insideMargin).toBe(true); }); test('place() accepts free coordinates and still honours the margin', async ({ page }) => { diff --git a/llms.txt b/llms.txt index 329d2e2..a7f9c80 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 === 6`. +Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 7`. ```js const bild = await window.Bildgenerator.render({ @@ -172,15 +172,21 @@ Linksbündig am linken Bildrand heißt also beides: `addText(t, { align: '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. +Es gibt kein Schriftgrößenfeld. Ein neuer Text wird automatisch so skaliert, +dass er in 80 % der Bildbreite Platz findet — 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. +`resize(faktor)` ändert das nachträglich, **relativ zu dieser eingefügten +Größe**: `1` lässt alles, wie es ist, `0.5` halbiert, `2` verdoppelt. + +Der Faktor ist bewusst relativ und kein absoluter Skalierungswert. Die +tatsächliche Skalierung einer eingefügten Schlagzeile liegt bei etwa 0,1 — +ein absolutes `0.35` würde den Text also rund dreimal **breiter** als das Bild +machen, genau umgekehrt zu dem, was die Zahl nahelegt. Für eine kleine Subline unter einer großen Headline ist etwa `0.4` bis `0.5` -ein brauchbarer Ausgangswert. +ein brauchbarer Ausgangswert. Prüf das Ergebnis mit `objects()`: Ist +`insideMargin` falsch, passt das Element nicht. ### Mehrere Texte diff --git a/resources/js/api.js b/resources/js/api.js index 30fd5c0..758720a 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: 6, + VERSION: 7, /** The object the most recent add* call put on the canvas. */ _lastAdded: null, @@ -339,17 +339,23 @@ const Bildgenerator = { } const [row, column] = this._resolvePosition(position); - const left = { + let left = { start: margin, centre: (canvas.width - width) / 2, end: canvas.width - margin - width, }[column]; - const top = { + let top = { start: margin, centre: (canvas.height - height) / 2, end: bottomLimit - height, }[row]; + // An element wider or taller than the usable area would otherwise be + // pushed off the canvas entirely. Clamp instead — objects() still + // reports insideMargin false, so the caller can see it does not fit. + left = Math.max(0, left); + top = Math.max(0, top); + target.set({ left: left, top: top }); target.setCoords(); canvas.renderAll(); @@ -361,10 +367,15 @@ const Bildgenerator = { * 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. + * The factor is RELATIVE TO THE SIZE IT WAS ADDED AT: 1 leaves it as the + * app placed it, 0.5 is half, 2 is double. + * + * Relative on purpose. The app inserts a text at whatever scale makes it + * fit 80 % of the canvas — for a longer headline that is around 0.1. An + * absolute resize(0.35) would therefore make it roughly three times WIDER + * than the canvas, the opposite of what the number suggests. * - * @param {number} factor absolute scale, not a multiplier + * @param {number} factor relative to the inserted size, must be positive * @param {Object} [options] * @param {fabric.Object} [options.target] default: the last element added */ @@ -372,10 +383,11 @@ const Bildgenerator = { 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) { + if (typeof factor !== "number" || !(factor > 0)) { throw new Error("resize() needs a positive number"); } - target.scale(factor).setCoords(); + const base = target._gatBaseScale || target.scaleX || 1; + target.scale(base * factor).setCoords(); canvas.renderAll(); return this; }, @@ -816,6 +828,13 @@ const Bildgenerator = { ); await this._waitUntilQuiet(); this._lastAdded = canvas.getObjects().find((o) => !before.has(o)) || null; + if (this._lastAdded) { + // The size the app gave it. resize() is relative to THIS, not to + // fabric's raw scale: a text is inserted at roughly 0.1, so an + // absolute resize(0.35) would make it three times wider than the + // canvas instead of a third of its size. + this._lastAdded._gatBaseScale = this._lastAdded.scaleX || 1; + } return this._lastAdded; }, From ef46059f4a0ee3b89ba653317d84b725d27a32ee Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 25 Sep 2026 14:24:34 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Stoerer=20mit=20Text=20ermoeglichen=20?= =?UTF-8?q?=E2=80=94=20die=20Absage=20war=20ueberholt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die llms.txt fuehrte unter "geht nicht", dass Text im Stoerer nicht machbar sei und die Schriftgroesse nicht steuerbar. Das stimmte, als es geschrieben wurde. Mit resize() stimmt es nicht mehr: Der Kreis misst 320x320, der Text "31.8." wird mit 858 px eingefuegt und passt verkleinert mit 192x108 hinein. Wieder eine falsche Angabe unter "geht nicht" — der Fehlertyp, bei dem ein Modell eine Anforderung ablehnt, die das Werkzeug erfuellen kann. Gefehlt hat tatsaechlich nur die Platzierung. Neu: - fitInto(index) verkleinert das zuletzt hinzugefuegte Element auf ein anderes, per Vorgabe auf 60 % von dessen Breite. - place({ onto: index }) legt es mittig darauf. Damit ist der Stoerer mit Text erstmals ueber die Schnittstelle baubar — die Form, die der CD-Quickguide zeigt. Das Beispiel in der llms.txt ist lauffaehig und wird vom Beispieltest mitgefahren. VERSION auf 8. Claude-Session: https://claude.ai/code/session_01Qtib9MPRZATQU3j9w7ymDk --- e2e/api.spec.js | 30 ++++++++++++++++++++++++++++++ llms.txt | 35 +++++++++++++++++++++++++---------- resources/js/api.js | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/e2e/api.spec.js b/e2e/api.spec.js index 71e7781..474c08f 100644 --- a/e2e/api.spec.js +++ b/e2e/api.spec.js @@ -522,6 +522,36 @@ test.describe('Bildgenerator facade', () => { expect(angles.cross).toBe(20); }); + test('a text can be fitted onto the Stoerer, as the brand guide shows it', async ({ page }) => { + const result = await page.evaluate(async () => { + const B = window.Bildgenerator; + await B.setTemplate('feed_post_45'); + await B.setLogoEnabled(false); + await B.addShape('pinkCircle'); + await B.place('top-right'); + + const kreis = B.objects().find((o) => o.type === 'circle'); + await B.addText('31.8.'); + const raw = B.lastAdded().getScaledWidth(); + await B.fitInto(kreis.index); + await B.place({ onto: kreis.index }); + + const text = B.lastAdded(); + const circle = canvas.getObjects()[kreis.index]; + return { + raw, + circle: { w: circle.getScaledWidth(), cx: circle.left + circle.getScaledWidth() / 2 }, + text: { w: text.getScaledWidth(), cx: text.left + text.getScaledWidth() / 2 }, + }; + }); + + // Inserted far wider than the circle, then shrunk to sit inside it. + expect(result.raw).toBeGreaterThan(result.circle.w); + expect(result.text.w).toBeLessThan(result.circle.w); + // And centred on it. + expect(result.text.cx).toBeCloseTo(result.circle.cx, 0); + }); + // --------------------------------------------------------------- failures test('rejects an unknown template by name and lists the valid ones', async ({ page }) => { diff --git a/llms.txt b/llms.txt index a7f9c80..e7f1ab9 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 === 7`. +Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 8`. ```js const bild = await window.Bildgenerator.render({ @@ -126,6 +126,7 @@ Objekt zurück, sind also verkettbar. | `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 | +| `fitInto(index)` | das zuletzt hinzugefügte Element auf ein anderes verkleinern | | `objects()` | alles auflisten, was auf dem Bild liegt | | `select(index)` | ein Element ansprechen | | `update(änderungen)` | ein gesetztes Element ändern | @@ -242,6 +243,9 @@ 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. +`place({ onto: index })` legt ein Element mittig auf ein anderes — gedacht für +den Störer mit Text, siehe unten. + ### Drehen `rotate(grad)` dreht das zuletzt hinzugefügte Element im Uhrzeigersinn. Der @@ -389,16 +393,27 @@ gegenüberliegende Felder. ### Störer -Der magentafarbene Kreis (`addShape('pinkCircle')`) ist der Störer aus dem CD, -allerdings **nur als Form**. Text hineinzusetzen funktioniert nicht: Der Kreis -misst rund 30 % der Bildbreite, während Text immer auf bis zu 80 % skaliert -wird — selbst „31.8." wird deutlich größer als der Kreis. Die Schriftgröße -lässt sich über die Schnittstelle nicht einzeln steuern. +Der magentafarbene Kreis (`addShape('pinkCircle')`) ist der Störer aus dem CD. +Im Quickguide trägt er einen kurzen Text — ein Datum, einen Zuruf, eine Zahl. + +Text wird immer auf bis zu 80 % der Bildbreite eingefügt und ist damit +zunächst viel größer als der Kreis. `fitInto()` verkleinert ihn auf den Kreis, +`place({ onto })` legt ihn mittig darauf: + +```js +const B = window.Bildgenerator; +await B.setTemplate('feed_post_45'); +await B.addShape('pinkCircle'); +await B.place('top-right'); + +const kreis = B.objects().find(o => o.type === 'circle'); +await B.addText('31.8.'); +await B.fitInto(kreis.index); // auf 60 % des Kreises verkleinern +await B.place({ onto: kreis.index }); // mittig darauflegen +``` -Setz den Kreis also als reinen Farbakzent ein, und sparsam: einer pro Sujet. -Mehrere Störer heben sich gegenseitig auf. Wenn ein Störer mit Text gebraucht -wird, ist das mit diesem Werkzeug derzeit nicht möglich — sag das, statt etwas -auszuliefern, das danebensitzt. +Sparsam einsetzen: einer pro Sujet. Mehrere Störer heben sich gegenseitig auf. +Der Störer ist ein Akzent und soll die Headline nicht kreuzen. ### Die Akzentschrift diff --git a/resources/js/api.js b/resources/js/api.js index 758720a..6629e75 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: 7, + VERSION: 8, /** The object the most recent add* call put on the canvas. */ _lastAdded: null, @@ -302,6 +302,21 @@ const Bildgenerator = { // 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. + // Centre on another element — the Stoerer pattern from the brand guide + // is a magenta circle with a short text on it, which needs exactly + // this and nothing else. + if (position && typeof position === "object" && position.onto !== undefined) { + const host = this._resolveTarget(position.onto); + if (host === target) throw new Error("place({ onto }) needs a different element"); + target.set({ + left: host.left + (host.getScaledWidth() - width) / 2, + top: host.top + (host.getScaledHeight() - height) / 2, + }); + target.setCoords(); + canvas.renderAll(); + return this; + } + if (position && typeof position === "object") { const inRange = function (v) { return v === undefined || (typeof v === "number" && v >= 0 && v <= 1); @@ -392,6 +407,26 @@ const Bildgenerator = { return this; }, + /** + * Scale an element so it fits inside another one, leaving a little air. + * Pairs with place({ onto }) for the Stoerer pattern. + * + * @param {number|Object} host index from objects(), or an element + * @param {number} [ratio=0.6] share of the host's width to occupy + */ + async fitInto(host, ratio) { + const target = this.lastAdded(); + if (!target) throw new Error("fitInto() needs an element that was added first"); + const into = this._resolveTarget(host); + const share = typeof ratio === "number" ? ratio : 0.6; + const current = target.getScaledWidth(); + if (!current) throw new Error("fitInto() cannot measure the element"); + const wanted = into.getScaledWidth() * share; + target.scale((target.scaleX || 1) * (wanted / current)).setCoords(); + canvas.renderAll(); + 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