@graph-knowledge/api 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,6 +92,7 @@ new GraphKnowledgeAPI(config: ApiConfig)
92
92
  | `signOut()` | Signs out the current user |
93
93
  | `waitForAuthInit()` | Waits for Firebase Auth to initialize |
94
94
  | `measureText(text, options?)` | Measures text dimensions (static method, no auth required) |
95
+ | `fitTextToShape(text, bounds, options?)` | Fits text into a shape's bounds (static method, no auth required) |
95
96
 
96
97
  #### Properties
97
98
 
@@ -185,6 +186,11 @@ const rect = await api.elements.create(documentId, nodeId, {
185
186
  });
186
187
 
187
188
  // Create text
189
+ // If you omit both `width` and `maxWidth`, the API will auto-measure the text
190
+ // and use a tight single-line bounding box. This is convenient for simple labels.
191
+ // For predictable wrapping or alignment in more complex layouts, explicitly set
192
+ // `width` or `maxWidth`. You can use measureText() to choose a width, or
193
+ // fitTextToShape() for text inside shapes.
188
194
  await api.elements.create(documentId, nodeId, {
189
195
  type: "text",
190
196
  x: 100,
@@ -219,6 +225,18 @@ await api.elements.create(documentId, nodeId, {
219
225
  });
220
226
  // Note: height is auto-calculated based on the number of lines and lineHeight
221
227
 
228
+ // Create text with auto-wrapping
229
+ // maxWidth sets the wrap boundary — text will wrap at word boundaries
230
+ await api.elements.create(documentId, nodeId, {
231
+ type: "text",
232
+ x: 100,
233
+ y: 300,
234
+ text: "This is a long paragraph that will automatically wrap within the specified width boundary.",
235
+ maxWidth: 400, // Text wraps at 400px
236
+ fontSize: 18
237
+ });
238
+ // Note: height is auto-calculated based on wrapped lines
239
+
222
240
  // Create a connector
223
241
  await api.elements.create(documentId, nodeId, {
224
242
  type: "connector",
@@ -450,6 +468,106 @@ npm install canvas
450
468
  Without it, the API uses character-based estimation (less accurate).
451
469
  The `canvas` package requires native compilation - see [node-canvas requirements](https://github.com/Automattic/node-canvas#compiling).
452
470
 
471
+ ### Text Width Behavior
472
+
473
+ When you create a text element via the API without `width` or `maxWidth`, the API **auto-measures** the text and sets the width to fit the content. This prevents the legacy 100px default that caused unexpected wrapping.
474
+
475
+ For predictable wrapping or text inside shapes, explicitly set `width` or `maxWidth`:
476
+
477
+ ```typescript
478
+ // Auto-measured — convenient for simple labels
479
+ await api.elements.create(docId, nodeId, {
480
+ type: "text", x: 100, y: 100,
481
+ text: "My Long Title",
482
+ fontSize: 24
483
+ });
484
+
485
+ // Explicit width — for controlled wrapping
486
+ const m = GraphKnowledgeAPI.measureText("My Long Title", { fontSize: 24 });
487
+ await api.elements.create(docId, nodeId, {
488
+ type: "text", x: 100, y: 100, width: m.width,
489
+ text: "My Long Title",
490
+ fontSize: 24
491
+ });
492
+
493
+ // fitTextToShape — for text inside shapes (sets maxWidth automatically)
494
+ const fit = GraphKnowledgeAPI.fitTextToShape("My Long Title", shapeBounds);
495
+ await api.elements.create(docId, nodeId, { type: "text", ...fit.textInput });
496
+ ```
497
+
498
+ ## Layout Helpers
499
+
500
+ Fit text into shapes with automatic wrapping, shrinking, or both. Returns a ready-to-use `textInput` for element creation.
501
+
502
+ ```typescript
503
+ import { GraphKnowledgeAPI } from "@graph-knowledge/api";
504
+
505
+ // Fit text into a rectangle (default: wrap strategy)
506
+ const result = GraphKnowledgeAPI.fitTextToShape("Hello World", {
507
+ x: 100, y: 100, width: 200, height: 100
508
+ });
509
+ console.log(result.fits); // true if text fits within bounds
510
+ console.log(result.fontSize); // final font size used
511
+
512
+ // Create the text element directly
513
+ await api.elements.create(docId, nodeId, { type: "text", ...result.textInput });
514
+ ```
515
+
516
+ ### Strategies
517
+
518
+ | Strategy | Behavior |
519
+ |----------|----------|
520
+ | `"wrap"` (default) | Wraps text at word boundaries. Reports `fits: false` if height overflows. |
521
+ | `"shrink"` | Reduces font size (down to `minFontSize`) until text fits in one line. |
522
+ | `"auto"` | Tries wrapping first. If height overflows, shrinks font size with wrapping. |
523
+
524
+ ```typescript
525
+ // Shrink: reduce font size to fit in one line
526
+ const shrunk = GraphKnowledgeAPI.fitTextToShape("A very long title", bounds, {
527
+ strategy: "shrink",
528
+ fontSize: 24,
529
+ minFontSize: 8
530
+ });
531
+
532
+ // Auto: wrap first, shrink if needed
533
+ const auto = GraphKnowledgeAPI.fitTextToShape("Long paragraph text...", bounds, {
534
+ strategy: "auto"
535
+ });
536
+ ```
537
+
538
+ ### FitTextOptions
539
+
540
+ | Option | Type | Default | Description |
541
+ |--------|------|---------|-------------|
542
+ | `fontSize` | `number` | `16` | Starting font size in pixels |
543
+ | `fontFamily` | `string` | `"Arial, sans-serif"` | Font family |
544
+ | `fontWeight` | `number` | `400` | Font weight (100-900) |
545
+ | `fillColor` | `string` | `"#000000"` | Text fill color |
546
+ | `textAlign` | `"left" \| "center" \| "right"` | `"center"` | Text alignment |
547
+ | `lineHeight` | `number` | `1.2` | Line height multiplier |
548
+ | `padding` | `number` | `8` | Padding between shape edge and text |
549
+ | `strategy` | `"wrap" \| "shrink" \| "auto"` | `"wrap"` | Fitting strategy |
550
+ | `minFontSize` | `number` | `8` | Minimum font size for shrink/auto |
551
+
552
+ ### Creating Documents with AI
553
+
554
+ Combine shapes and fitted text for programmatic document creation:
555
+
556
+ ```typescript
557
+ // Create a labeled rectangle
558
+ const rect = await api.elements.create(docId, nodeId, {
559
+ type: "rectangle",
560
+ x: 100, y: 100, width: 200, height: 80,
561
+ fillColor: "#E3F2FD"
562
+ });
563
+
564
+ const label = GraphKnowledgeAPI.fitTextToShape("User Service", {
565
+ x: 100, y: 100, width: 200, height: 80
566
+ }, { fontSize: 18, strategy: "auto" });
567
+
568
+ await api.elements.create(docId, nodeId, { type: "text", ...label.textInput });
569
+ ```
570
+
453
571
  ## Error Handling
454
572
 
455
573
  The API throws typed errors for different failure cases:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graph-knowledge/api",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "Headless Document API for Graph Knowledge - programmatic access to documents, nodes, and elements",
5
5
  "license": "MIT",
6
6
  "author": "Graph Knowledge Team",
package/src/index.d.ts CHANGED
@@ -17,5 +17,7 @@ export { LinkLevelManager } from "./lib/utils/link-level-manager";
17
17
  export type { LevelChange, LinkState } from "./lib/utils/link-level-manager";
18
18
  export type { MeasureTextOptions, TextMetrics } from "./lib/types/text-measurement-types";
19
19
  export { TextMeasurementService } from "./lib/utils/text-measurement/text-measurement-service";
20
+ export { LayoutHelper } from "./lib/utils/layout/layout-helper";
21
+ export type { FitTextOptions, FitTextResult, FitTextStrategy, ShapeBounds } from "./lib/types/layout-types";
20
22
  export { MockAuthClient } from "./lib/testing/mock-auth-client";
21
23
  export { MockFirestoreClient } from "./lib/testing/mock-firestore-client";
package/src/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Graph Knowledge Headless Document API
4
4
  // =============================================================================
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.MockFirestoreClient = exports.MockAuthClient = exports.TextMeasurementService = exports.LinkLevelManager = exports.CURRENT_DOCUMENT_SCHEMA_VERSION = exports.PermissionError = exports.ValidationError = exports.NotFoundError = exports.AuthenticationError = exports.GraphKnowledgeAPIError = exports.GraphKnowledgeAPI = void 0;
6
+ exports.MockFirestoreClient = exports.MockAuthClient = exports.LayoutHelper = exports.TextMeasurementService = exports.LinkLevelManager = exports.CURRENT_DOCUMENT_SCHEMA_VERSION = exports.PermissionError = exports.ValidationError = exports.NotFoundError = exports.AuthenticationError = exports.GraphKnowledgeAPIError = exports.GraphKnowledgeAPI = void 0;
7
7
  // Main API Class
8
8
  var graph_knowledge_api_1 = require("./lib/graph-knowledge-api");
9
9
  Object.defineProperty(exports, "GraphKnowledgeAPI", { enumerable: true, get: function () { return graph_knowledge_api_1.GraphKnowledgeAPI; } });
@@ -25,6 +25,9 @@ var link_level_manager_1 = require("./lib/utils/link-level-manager");
25
25
  Object.defineProperty(exports, "LinkLevelManager", { enumerable: true, get: function () { return link_level_manager_1.LinkLevelManager; } });
26
26
  var text_measurement_service_1 = require("./lib/utils/text-measurement/text-measurement-service");
27
27
  Object.defineProperty(exports, "TextMeasurementService", { enumerable: true, get: function () { return text_measurement_service_1.TextMeasurementService; } });
28
+ // Layout
29
+ var layout_helper_1 = require("./lib/utils/layout/layout-helper");
30
+ Object.defineProperty(exports, "LayoutHelper", { enumerable: true, get: function () { return layout_helper_1.LayoutHelper; } });
28
31
  // =============================================================================
29
32
  // Testing Utilities
30
33
  // =============================================================================
@@ -6,6 +6,7 @@ import { IElementOperations } from "./interfaces/element-operations.interface";
6
6
  import { IBatchOperations } from "./interfaces/batch-operations.interface";
7
7
  import { ITemplateOperations } from "./interfaces/template-operations.interface";
8
8
  import { MeasureTextOptions, TextMetrics } from "./types/text-measurement-types";
9
+ import { FitTextOptions, FitTextResult, ShapeBounds } from "./types/layout-types";
9
10
  /**
10
11
  * Main entry point for the Graph Knowledge Headless API.
11
12
  *
@@ -55,6 +56,10 @@ export declare class GraphKnowledgeAPI {
55
56
  * Shared text measurement service for static and instance methods.
56
57
  */
57
58
  private static _textMeasurementService;
59
+ /**
60
+ * Shared layout helper for static and instance methods.
61
+ */
62
+ private static _layoutHelper;
58
63
  /**
59
64
  * Creates a new GraphKnowledgeAPI instance.
60
65
  * @param config Configuration including Firebase credentials
@@ -146,4 +151,32 @@ export declare class GraphKnowledgeAPI {
146
151
  * ```
147
152
  */
148
153
  measureText(text: string, options?: MeasureTextOptions): TextMetrics;
154
+ /**
155
+ * Fits text into a shape's bounding box, returning a ready-to-use TextInput.
156
+ * No authentication required.
157
+ *
158
+ * @param text The text to fit
159
+ * @param bounds The shape's bounding box (x, y, width, height)
160
+ * @param options Fitting options (strategy, font, padding, etc.)
161
+ * @returns FitTextResult with textInput ready for element creation
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * const result = GraphKnowledgeAPI.fitTextToShape("Hello World", {
166
+ * x: 100, y: 100, width: 200, height: 100
167
+ * });
168
+ * await api.elements.create(docId, nodeId, { type: "text", ...result.textInput });
169
+ * ```
170
+ */
171
+ static fitTextToShape(text: string, bounds: ShapeBounds, options?: FitTextOptions): FitTextResult;
172
+ /**
173
+ * Fits text into a shape's bounding box, returning a ready-to-use TextInput.
174
+ * Instance method that delegates to the static method.
175
+ *
176
+ * @param text The text to fit
177
+ * @param bounds The shape's bounding box (x, y, width, height)
178
+ * @param options Fitting options (strategy, font, padding, etc.)
179
+ * @returns FitTextResult with textInput ready for element creation
180
+ */
181
+ fitTextToShape(text: string, bounds: ShapeBounds, options?: FitTextOptions): FitTextResult;
149
182
  }
@@ -16,6 +16,7 @@ const node_validator_1 = require("./validators/node-validator");
16
16
  const element_validator_registry_1 = require("./validators/element-validator-registry");
17
17
  const template_validator_1 = require("./validators/template-validator");
18
18
  const text_measurement_service_1 = require("./utils/text-measurement/text-measurement-service");
19
+ const layout_helper_1 = require("./utils/layout/layout-helper");
19
20
  /**
20
21
  * Main entry point for the Graph Knowledge Headless API.
21
22
  *
@@ -65,6 +66,10 @@ class GraphKnowledgeAPI {
65
66
  * Shared text measurement service for static and instance methods.
66
67
  */
67
68
  static _textMeasurementService = null;
69
+ /**
70
+ * Shared layout helper for static and instance methods.
71
+ */
72
+ static _layoutHelper = null;
68
73
  /**
69
74
  * Creates a new GraphKnowledgeAPI instance.
70
75
  * @param config Configuration including Firebase credentials
@@ -211,5 +216,46 @@ class GraphKnowledgeAPI {
211
216
  measureText(text, options = {}) {
212
217
  return GraphKnowledgeAPI.measureText(text, options);
213
218
  }
219
+ // =========================================================================
220
+ // Layout Helpers (static and instance methods)
221
+ // =========================================================================
222
+ /**
223
+ * Fits text into a shape's bounding box, returning a ready-to-use TextInput.
224
+ * No authentication required.
225
+ *
226
+ * @param text The text to fit
227
+ * @param bounds The shape's bounding box (x, y, width, height)
228
+ * @param options Fitting options (strategy, font, padding, etc.)
229
+ * @returns FitTextResult with textInput ready for element creation
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * const result = GraphKnowledgeAPI.fitTextToShape("Hello World", {
234
+ * x: 100, y: 100, width: 200, height: 100
235
+ * });
236
+ * await api.elements.create(docId, nodeId, { type: "text", ...result.textInput });
237
+ * ```
238
+ */
239
+ static fitTextToShape(text, bounds, options) {
240
+ if (!GraphKnowledgeAPI._layoutHelper) {
241
+ if (!GraphKnowledgeAPI._textMeasurementService) {
242
+ GraphKnowledgeAPI._textMeasurementService = new text_measurement_service_1.TextMeasurementService();
243
+ }
244
+ GraphKnowledgeAPI._layoutHelper = new layout_helper_1.LayoutHelper(GraphKnowledgeAPI._textMeasurementService.provider);
245
+ }
246
+ return GraphKnowledgeAPI._layoutHelper.fitTextToShape(text, bounds, options);
247
+ }
248
+ /**
249
+ * Fits text into a shape's bounding box, returning a ready-to-use TextInput.
250
+ * Instance method that delegates to the static method.
251
+ *
252
+ * @param text The text to fit
253
+ * @param bounds The shape's bounding box (x, y, width, height)
254
+ * @param options Fitting options (strategy, font, padding, etc.)
255
+ * @returns FitTextResult with textInput ready for element creation
256
+ */
257
+ fitTextToShape(text, bounds, options) {
258
+ return GraphKnowledgeAPI.fitTextToShape(text, bounds, options);
259
+ }
214
260
  }
215
261
  exports.GraphKnowledgeAPI = GraphKnowledgeAPI;
@@ -25,6 +25,12 @@ export declare class ElementOperations implements IElementOperations {
25
25
  * Normalizes rotation to integer in range [0, 360).
26
26
  */
27
27
  private normalizeRotation;
28
+ /**
29
+ * Auto-measures text width for text elements created without explicit width/maxWidth.
30
+ * Uses a lazily-initialized TextMeasurementService singleton.
31
+ */
32
+ private measureTextWidth;
33
+ private static _textMeasurementService;
28
34
  /**
29
35
  * Builds properties object from element input.
30
36
  * Extracts type-specific properties, excluding common fields.
@@ -4,6 +4,7 @@ exports.ElementOperations = void 0;
4
4
  const uuid_1 = require("../utils/uuid");
5
5
  const api_errors_1 = require("../errors/api-errors");
6
6
  const element_defaults_1 = require("../constants/element-defaults");
7
+ const text_measurement_service_1 = require("../utils/text-measurement/text-measurement-service");
7
8
  /**
8
9
  * Implementation of element CRUD operations.
9
10
  */
@@ -54,12 +55,25 @@ class ElementOperations {
54
55
  // Connectors have optional x/y since their position is derived from connected elements
55
56
  const x = input.x ?? 0;
56
57
  const y = input.y ?? 0;
58
+ // For text elements, maxWidth is a convenience alias for width (the wrap boundary)
59
+ let resolvedWidth = input.width;
60
+ if (input.type === "text" && resolvedWidth === undefined) {
61
+ const textInput = input;
62
+ if (textInput.maxWidth !== undefined) {
63
+ resolvedWidth = textInput.maxWidth;
64
+ }
65
+ else {
66
+ // Auto-measure text width to avoid the 100px default which causes
67
+ // unexpected wrapping in the editor.
68
+ resolvedWidth = this.measureTextWidth(textInput);
69
+ }
70
+ }
57
71
  const element = {
58
72
  id: (0, uuid_1.generateUuid)(),
59
73
  elementType: input.type,
60
74
  x,
61
75
  y,
62
- width: input.width ?? defaults.width,
76
+ width: resolvedWidth ?? defaults.width,
63
77
  height: input.height ?? defaults.height,
64
78
  rotation: this.normalizeRotation(input.rotation ?? 0),
65
79
  properties: this.buildProperties(input)
@@ -149,6 +163,24 @@ class ElementOperations {
149
163
  }
150
164
  return Math.round(normalized);
151
165
  }
166
+ /**
167
+ * Auto-measures text width for text elements created without explicit width/maxWidth.
168
+ * Uses a lazily-initialized TextMeasurementService singleton.
169
+ */
170
+ measureTextWidth(input) {
171
+ if (!ElementOperations._textMeasurementService) {
172
+ ElementOperations._textMeasurementService = new text_measurement_service_1.TextMeasurementService();
173
+ }
174
+ const metrics = ElementOperations._textMeasurementService.measureText(input.text, {
175
+ fontSize: input.fontSize,
176
+ fontFamily: input.fontFamily,
177
+ fontWeight: input.fontWeight,
178
+ textAlign: input.textAlign,
179
+ lineHeight: input.lineHeight
180
+ });
181
+ return Math.ceil(metrics.width);
182
+ }
183
+ static _textMeasurementService = null;
152
184
  /**
153
185
  * Builds properties object from element input.
154
186
  * Extracts type-specific properties, excluding common fields.
@@ -157,7 +189,7 @@ class ElementOperations {
157
189
  const props = {
158
190
  __schemaVersion: 1
159
191
  };
160
- // Exclude common fields, copy the rest as properties
192
+ // Exclude common fields and convenience aliases, copy the rest as properties
161
193
  const commonFields = new Set([
162
194
  "type",
163
195
  "x",
@@ -166,27 +198,22 @@ class ElementOperations {
166
198
  "height",
167
199
  "rotation",
168
200
  "isLink",
169
- "linkTarget"
201
+ "linkTarget",
202
+ "maxWidth"
170
203
  ]);
171
204
  for (const [key, value] of Object.entries(input)) {
172
205
  if (!commonFields.has(key) && value !== undefined) {
173
206
  props[key] = value;
174
207
  }
175
208
  }
176
- // For text elements with an explicitly specified width, set __usesTopLeft: true
177
- // so the app's normalization knows that x/y is the bounding box top-left
178
- // (not the text anchor point). Without this, the app would incorrectly adjust
179
- // x/y based on textAlign, causing centered text to be positioned in negative
180
- // coordinates.
181
- //
182
- // However, when width is omitted, a default width is applied elsewhere. In that
183
- // case we DO NOT set __usesTopLeft, so the text plugin can treat the element as
184
- // auto-sized text instead of preserving the default container width.
209
+ // For text elements, set __usesTopLeft: true so the app's normalization
210
+ // knows that x/y is the bounding box top-left (not the text anchor point).
211
+ // Without this, the app would incorrectly adjust x/y based on textAlign,
212
+ // causing centered text to be positioned in negative coordinates.
213
+ // Since we now always resolve a proper width for text (via explicit width,
214
+ // maxWidth, or auto-measurement), we always set this flag.
185
215
  if (input.type === "text") {
186
- const hasExplicitWidth = Object.prototype.hasOwnProperty.call(input, "width");
187
- if (hasExplicitWidth) {
188
- props["__usesTopLeft"] = true;
189
- }
216
+ props["__usesTopLeft"] = true;
190
217
  }
191
218
  return props;
192
219
  }
@@ -229,19 +229,13 @@ class TemplateOperations {
229
229
  const clonedProperties = { ...element.properties };
230
230
  // Update connector endpoint references if this is a connector
231
231
  if (element.elementType === "connector" && clonedProperties) {
232
- if (clonedProperties["startAnchor"]?.elementId &&
233
- idMapping.has(clonedProperties["startAnchor"].elementId)) {
234
- clonedProperties["startAnchor"] = {
235
- ...clonedProperties["startAnchor"],
236
- elementId: idMapping.get(clonedProperties["startAnchor"].elementId)
237
- };
232
+ if (clonedProperties["startElementId"] &&
233
+ idMapping.has(clonedProperties["startElementId"])) {
234
+ clonedProperties["startElementId"] = idMapping.get(clonedProperties["startElementId"]);
238
235
  }
239
- if (clonedProperties["endAnchor"]?.elementId &&
240
- idMapping.has(clonedProperties["endAnchor"].elementId)) {
241
- clonedProperties["endAnchor"] = {
242
- ...clonedProperties["endAnchor"],
243
- elementId: idMapping.get(clonedProperties["endAnchor"].elementId)
244
- };
236
+ if (clonedProperties["endElementId"] &&
237
+ idMapping.has(clonedProperties["endElementId"])) {
238
+ clonedProperties["endElementId"] = idMapping.get(clonedProperties["endElementId"]);
245
239
  }
246
240
  }
247
241
  const clonedElement = {
@@ -50,6 +50,8 @@ export interface TextInput extends BaseElementInput {
50
50
  textAlign?: "left" | "center" | "right";
51
51
  /** Line height multiplier */
52
52
  lineHeight?: number;
53
+ /** Maximum width for text wrapping. Text will auto-wrap at word boundaries within this width. */
54
+ maxWidth?: number;
53
55
  }
54
56
  /**
55
57
  * Anchor points for connectors.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Strategy for fitting text within a shape.
3
+ *
4
+ * - `wrap`: Wrap text at word boundaries within the shape width (default)
5
+ * - `shrink`: Reduce font size until text fits in a single line
6
+ * - `auto`: Try wrapping first; if height overflows, shrink font size with wrapping
7
+ */
8
+ export type FitTextStrategy = "wrap" | "shrink" | "auto";
9
+ /**
10
+ * Bounding box of a shape to fit text into.
11
+ */
12
+ export interface ShapeBounds {
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+ /**
19
+ * Options for fitting text into a shape.
20
+ * All properties are optional and have sensible defaults.
21
+ */
22
+ export interface FitTextOptions {
23
+ /** Font family. @default "Arial, sans-serif" */
24
+ fontFamily?: string;
25
+ /** Font size in pixels. @default 16 */
26
+ fontSize?: number;
27
+ /** Font weight (100-900). @default 400 */
28
+ fontWeight?: number;
29
+ /** Fill color for the text. @default "#000000" */
30
+ fillColor?: string;
31
+ /** Text alignment within the shape. @default "center" */
32
+ textAlign?: "left" | "center" | "right";
33
+ /** Line height multiplier. @default 1.2 */
34
+ lineHeight?: number;
35
+ /** Padding in pixels between shape edge and text. @default 8 */
36
+ padding?: number;
37
+ /** Strategy for fitting text. @default "wrap" */
38
+ strategy?: FitTextStrategy;
39
+ /** Minimum font size for shrink/auto strategies. @default 8 */
40
+ minFontSize?: number;
41
+ }
42
+ /**
43
+ * Result of fitting text into a shape.
44
+ */
45
+ export interface FitTextResult {
46
+ /**
47
+ * Ready to spread into api.elements.create(docId, nodeId, { type: "text", ...textInput }).
48
+ */
49
+ textInput: {
50
+ x: number;
51
+ y: number;
52
+ text: string;
53
+ maxWidth?: number;
54
+ fontSize: number;
55
+ fontFamily: string;
56
+ fontWeight: number;
57
+ fillColor: string;
58
+ textAlign: "left" | "center" | "right";
59
+ lineHeight: number;
60
+ };
61
+ /** Final font size used (may differ from input if shrunk). */
62
+ fontSize: number;
63
+ /** Number of lines after fitting. */
64
+ lines: number;
65
+ /** Width of the measured text. */
66
+ textWidth: number;
67
+ /** Height of the measured text. */
68
+ textHeight: number;
69
+ /** False if text overflows the shape even after applying the strategy. */
70
+ fits: boolean;
71
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,37 @@
1
+ import { ITextMeasurementProvider } from "../../interfaces/text-measurement.interface";
2
+ import { FitTextOptions, FitTextResult, ShapeBounds } from "../../types/layout-types";
3
+ /**
4
+ * Pure layout utility for fitting text into shapes.
5
+ * No Firestore/Auth dependencies — only requires a text measurement provider.
6
+ */
7
+ export declare class LayoutHelper {
8
+ private readonly _measurementProvider;
9
+ constructor(_measurementProvider: ITextMeasurementProvider);
10
+ /**
11
+ * Fits text into a shape's bounding box, returning a ready-to-use TextInput.
12
+ *
13
+ * @param text The text to fit
14
+ * @param bounds The shape's bounding box
15
+ * @param options Fitting options (strategy, font, padding, etc.)
16
+ * @returns FitTextResult with textInput ready for element creation
17
+ */
18
+ fitTextToShape(text: string, bounds: ShapeBounds, options?: FitTextOptions): FitTextResult;
19
+ /**
20
+ * Wrap strategy: set maxWidth and measure. Report fits: false if height overflows.
21
+ */
22
+ private _fitWrap;
23
+ /**
24
+ * Shrink strategy: binary search font size until single-line text fits width.
25
+ */
26
+ private _fitShrink;
27
+ /**
28
+ * Auto strategy: try wrap first. If height overflows, binary search font size with wrapping.
29
+ */
30
+ private _fitAuto;
31
+ /** Measure without wrapping. */
32
+ private _measure;
33
+ /** Measure with wrapping at maxWidth. */
34
+ private _measureWrapped;
35
+ /** Build the final FitTextResult with centered positioning. */
36
+ private _buildResult;
37
+ }
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LayoutHelper = void 0;
4
+ const FIT_TEXT_DEFAULTS = {
5
+ fontFamily: "Arial, sans-serif",
6
+ fontSize: 16,
7
+ fontWeight: 400,
8
+ fillColor: "#000000",
9
+ textAlign: "center",
10
+ lineHeight: 1.2,
11
+ padding: 8,
12
+ strategy: "wrap",
13
+ minFontSize: 8
14
+ };
15
+ /**
16
+ * Pure layout utility for fitting text into shapes.
17
+ * No Firestore/Auth dependencies — only requires a text measurement provider.
18
+ */
19
+ class LayoutHelper {
20
+ _measurementProvider;
21
+ constructor(_measurementProvider) {
22
+ this._measurementProvider = _measurementProvider;
23
+ }
24
+ /**
25
+ * Fits text into a shape's bounding box, returning a ready-to-use TextInput.
26
+ *
27
+ * @param text The text to fit
28
+ * @param bounds The shape's bounding box
29
+ * @param options Fitting options (strategy, font, padding, etc.)
30
+ * @returns FitTextResult with textInput ready for element creation
31
+ */
32
+ fitTextToShape(text, bounds, options) {
33
+ const opts = { ...FIT_TEXT_DEFAULTS, ...options };
34
+ const contentWidth = Math.max(0, bounds.width - 2 * opts.padding);
35
+ const contentHeight = Math.max(0, bounds.height - 2 * opts.padding);
36
+ if (!text) {
37
+ return this._buildResult(text, bounds, opts, 0, 0, 0, opts.fontSize, true);
38
+ }
39
+ switch (opts.strategy) {
40
+ case "shrink":
41
+ return this._fitShrink(text, bounds, opts, contentWidth, contentHeight);
42
+ case "auto":
43
+ return this._fitAuto(text, bounds, opts, contentWidth, contentHeight);
44
+ case "wrap":
45
+ default:
46
+ return this._fitWrap(text, bounds, opts, contentWidth, contentHeight);
47
+ }
48
+ }
49
+ /**
50
+ * Wrap strategy: set maxWidth and measure. Report fits: false if height overflows.
51
+ */
52
+ _fitWrap(text, bounds, opts, contentWidth, contentHeight) {
53
+ const metrics = this._measurementProvider.measureText(text, {
54
+ fontSize: opts.fontSize,
55
+ fontFamily: opts.fontFamily,
56
+ fontWeight: opts.fontWeight,
57
+ textAlign: opts.textAlign,
58
+ lineHeight: opts.lineHeight,
59
+ maxWidth: contentWidth
60
+ });
61
+ const fits = metrics.height <= contentHeight;
62
+ return this._buildResult(text, bounds, opts, metrics.width, metrics.height, metrics.lines, opts.fontSize, fits);
63
+ }
64
+ /**
65
+ * Shrink strategy: binary search font size until single-line text fits width.
66
+ */
67
+ _fitShrink(text, bounds, opts, contentWidth, contentHeight) {
68
+ let lo = opts.minFontSize;
69
+ let hi = opts.fontSize;
70
+ let bestSize = lo;
71
+ // If text fits at full size, no need to shrink
72
+ const fullMetrics = this._measure(text, hi, opts);
73
+ if (fullMetrics.width <= contentWidth && fullMetrics.height <= contentHeight) {
74
+ return this._buildResult(text, bounds, opts, fullMetrics.width, fullMetrics.height, fullMetrics.lines, hi, true);
75
+ }
76
+ // Binary search for largest font size that fits
77
+ while (hi - lo > 0.5) {
78
+ const mid = (lo + hi) / 2;
79
+ const metrics = this._measure(text, mid, opts);
80
+ if (metrics.width <= contentWidth && metrics.height <= contentHeight) {
81
+ bestSize = mid;
82
+ lo = mid;
83
+ }
84
+ else {
85
+ hi = mid;
86
+ }
87
+ }
88
+ const finalSize = Math.floor(bestSize);
89
+ const finalMetrics = this._measure(text, finalSize, opts);
90
+ const fits = finalMetrics.width <= contentWidth && finalMetrics.height <= contentHeight;
91
+ return this._buildResult(text, bounds, opts, finalMetrics.width, finalMetrics.height, finalMetrics.lines, finalSize, fits);
92
+ }
93
+ /**
94
+ * Auto strategy: try wrap first. If height overflows, binary search font size with wrapping.
95
+ */
96
+ _fitAuto(text, bounds, opts, contentWidth, contentHeight) {
97
+ // Try wrapping at current font size
98
+ const wrapMetrics = this._measureWrapped(text, opts.fontSize, opts, contentWidth);
99
+ if (wrapMetrics.height <= contentHeight) {
100
+ return this._buildResult(text, bounds, opts, wrapMetrics.width, wrapMetrics.height, wrapMetrics.lines, opts.fontSize, true);
101
+ }
102
+ // Binary search font size with wrapping
103
+ let lo = opts.minFontSize;
104
+ let hi = opts.fontSize;
105
+ let bestSize = lo;
106
+ while (hi - lo > 0.5) {
107
+ const mid = (lo + hi) / 2;
108
+ const metrics = this._measureWrapped(text, mid, opts, contentWidth);
109
+ if (metrics.height <= contentHeight) {
110
+ bestSize = mid;
111
+ lo = mid;
112
+ }
113
+ else {
114
+ hi = mid;
115
+ }
116
+ }
117
+ const finalSize = Math.floor(bestSize);
118
+ const finalMetrics = this._measureWrapped(text, finalSize, opts, contentWidth);
119
+ const fits = finalMetrics.height <= contentHeight;
120
+ return this._buildResult(text, bounds, opts, finalMetrics.width, finalMetrics.height, finalMetrics.lines, finalSize, fits);
121
+ }
122
+ /** Measure without wrapping. */
123
+ _measure(text, fontSize, opts) {
124
+ return this._measurementProvider.measureText(text, {
125
+ fontSize,
126
+ fontFamily: opts.fontFamily,
127
+ fontWeight: opts.fontWeight,
128
+ textAlign: opts.textAlign,
129
+ lineHeight: opts.lineHeight
130
+ });
131
+ }
132
+ /** Measure with wrapping at maxWidth. */
133
+ _measureWrapped(text, fontSize, opts, maxWidth) {
134
+ return this._measurementProvider.measureText(text, {
135
+ fontSize,
136
+ fontFamily: opts.fontFamily,
137
+ fontWeight: opts.fontWeight,
138
+ textAlign: opts.textAlign,
139
+ lineHeight: opts.lineHeight,
140
+ maxWidth
141
+ });
142
+ }
143
+ /** Build the final FitTextResult with centered positioning. */
144
+ _buildResult(text, bounds, opts, textWidth, textHeight, lines, fontSize, fits) {
145
+ const contentWidth = Math.max(0, bounds.width - 2 * opts.padding);
146
+ const contentHeight = Math.max(0, bounds.height - 2 * opts.padding);
147
+ const x = bounds.x + opts.padding;
148
+ const y = bounds.y + opts.padding + (contentHeight - textHeight) / 2;
149
+ const textInput = {
150
+ x,
151
+ y,
152
+ text,
153
+ fontSize,
154
+ fontFamily: opts.fontFamily,
155
+ fontWeight: opts.fontWeight,
156
+ fillColor: opts.fillColor,
157
+ textAlign: opts.textAlign,
158
+ lineHeight: opts.lineHeight
159
+ };
160
+ if (contentWidth > 0) {
161
+ textInput.maxWidth = contentWidth;
162
+ }
163
+ return {
164
+ textInput,
165
+ fontSize,
166
+ lines,
167
+ textWidth,
168
+ textHeight,
169
+ fits
170
+ };
171
+ }
172
+ }
173
+ exports.LayoutHelper = LayoutHelper;
@@ -11,4 +11,5 @@ export declare class TextValidator extends BaseElementValidator implements IElem
11
11
  private validateFontWeight;
12
12
  private validateTextAlign;
13
13
  private validateLineHeight;
14
+ private validateMaxWidth;
14
15
  }
@@ -28,6 +28,7 @@ class TextValidator extends base_element_validator_1.BaseElementValidator {
28
28
  this.validateFontWeight(textInput.fontWeight);
29
29
  this.validateTextAlign(textInput.textAlign);
30
30
  this.validateLineHeight(textInput.lineHeight);
31
+ this.validateMaxWidth(textInput.maxWidth);
31
32
  }
32
33
  validateFontSize(value) {
33
34
  if (value === undefined)
@@ -66,5 +67,15 @@ class TextValidator extends base_element_validator_1.BaseElementValidator {
66
67
  throw new api_errors_1.ValidationError("lineHeight must be positive", "lineHeight");
67
68
  }
68
69
  }
70
+ validateMaxWidth(value) {
71
+ if (value === undefined)
72
+ return;
73
+ if (typeof value !== "number" || isNaN(value)) {
74
+ throw new api_errors_1.ValidationError("maxWidth must be a valid number", "maxWidth");
75
+ }
76
+ if (value <= 0) {
77
+ throw new api_errors_1.ValidationError("maxWidth must be positive", "maxWidth");
78
+ }
79
+ }
69
80
  }
70
81
  exports.TextValidator = TextValidator;