diff --git a/e2e/api.spec.js b/e2e/api.spec.js index 64e000e..474c08f 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 }; }); - expect(result.after).toBeLessThan(result.before); + // 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'); + }); + + // 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 }) => { @@ -496,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 329d2e2..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 === 6`. +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 | @@ -172,15 +173,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 @@ -236,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 @@ -383,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 30fd5c0..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: 6, + 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); @@ -339,17 +354,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 +382,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 +398,31 @@ 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; + }, + + /** + * 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; }, @@ -816,6 +863,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; },