Repository: iTwin/itwinjs-core
Pull request #7706: TextBlock margins
Author: login: claudiaareneee
Created: 2025-02-14T18:14:46Z | Merged: 2025-03-14T18:25:25Z

**Changes**

* Creates a new interface called `TextBlockMargins`
* Adds a new attribute called `margins` to `TextBlock` of the type above
* Draws additional lines to the `debugAnchorPoint` geometry to show the margins
* Adds a `marginRange` to the `TextBlockLayout` class that expands the original `range` to include the specified margins.
* Does this in a method called `applyMargins`
* Adds a test to cover changes
* Adds keyins to set the margin in `display-test-app`
  * `dta text margin <'left', 'right', 'top', 'bottom'> <number>`

**Screenshots**

This was taken in `display-test-app`

![keyins](https://github.com/user-attachments/assets/e2d44650-997f-4681-a537-210b54d96c33)

**Associated issue** 
* Closes #7675


## Edit: 

After all the changes, this is what it looks like now:

https://github.com/user-attachments/assets/252cb7b3-3ce6-41d8-850f-a4f36485bc64




Linked issue #7675: TextAnnotations - TextBlocks should have a property for margins
<!--
Thanks for helping make iTwin.js better!

When suggesting an idea, give examples of the intended use case. Features that benefit the wider community are more likely to be prioritized.

The best way to get your ideas into iTwin.js is to help us! We love contributions and are always happy to be provide feedback and advice. Check out the contributor guide to get started:

https://github.com/iTwin/itwinjs-core/blob/master/CONTRIBUTING.md
-->

**Is your feature request related to a problem? Please describe.**

I would like to be able to specify margins around a `TextAnnotation` similar to how we describe the `width`. 

**Describe the solution you'd like**

`TextBlock` should have a property called `margins` with the following attributes
* Either: `top`, `bottom`, `left`, `right`
* Or: `vertical`, `horizontal`
* Or both
* Also units should be meters

Linked issue comments: user: login: claudiaareneee
created_at: 2025-02-13T17:24:22Z
body: Hi @pmconne, I have an initial fix for this issue. I'm thinking of adding a new range to the `TextBlockLayout` called `marginRange`. This range will extend the range of the text block to include margins specified by a new `margins` property of the `TextBlock`. 

Do you have any issues or concerns for this approach?
author_association: CONTRIBUTOR

Diff:
diff --git a/common/api/core-common.api.md b/common/api/core-common.api.md
index 3eaee41dfa28..0b8a60ad22b7 100644
--- a/common/api/core-common.api.md
+++ b/common/api/core-common.api.md
@@ -9772,6 +9772,7 @@ export class TextBlock extends TextBlockComponent {
     equals(other: TextBlockComponent): boolean;
     get isEmpty(): boolean;
     justification: TextBlockJustification;
+    margins: TextBlockMargins;
     readonly paragraphs: Paragraph[];
     stringify(options?: TextBlockStringifyOptions): string;
     // (undocumented)
@@ -9834,9 +9835,18 @@ export interface TextBlockLayoutResult {
     range: Range2dProps;
 }
 
+// @beta
+export interface TextBlockMargins {
+    bottom: number;
+    left: number;
+    right: number;
+    top: number;
+}
+
 // @beta
 export interface TextBlockProps extends TextBlockComponentProps {
     justification?: TextBlockJustification;
+    margins?: Partial<TextBlockMargins>;
     paragraphs?: ParagraphProps[];
     width?: number;
 }
diff --git a/common/api/summary/core-common.exports.csv b/common/api/summary/core-common.exports.csv
index 84fa4d84a431..bb3195de6f95 100644
--- a/common/api/summary/core-common.exports.csv
+++ b/common/api/summary/core-common.exports.csv
@@ -859,6 +859,7 @@ beta;interface;TextBlockGeometryProps
 beta;type;TextBlockGeometryPropsEntry
 beta;type;TextBlockJustification
 beta;interface;TextBlockLayoutResult
+beta;interface;TextBlockMargins
 beta;interface;TextBlockProps
 beta;interface;TextBlockStringifyOptions
 beta;class;TextRun
diff --git a/common/changes/@itwin/core-backend/claudaareneee-text-margins_2025-02-14-17-59.json b/common/changes/@itwin/core-backend/claudaareneee-text-margins_2025-02-14-17-59.json
new file mode 100644
index 000000000000..a2391334e9a4
--- /dev/null
+++ b/common/changes/@itwin/core-backend/claudaareneee-text-margins_2025-02-14-17-59.json
@@ -0,0 +1,10 @@
+{
+  "changes": [
+    {
+      "packageName": "@itwin/core-backend",
+      "comment": "Adding support for margins to `TextBlock`",
+      "type": "none"
+    }
+  ],
+  "packageName": "@itwin/core-backend"
+}
\ No newline at end of file
diff --git a/common/changes/@itwin/core-common/claudaareneee-text-margins_2025-02-14-17-59.json b/common/changes/@itwin/core-common/claudaareneee-text-margins_2025-02-14-17-59.json
new file mode 100644
index 000000000000..93e2c46576b1
--- /dev/null
+++ b/common/changes/@itwin/core-common/claudaareneee-text-margins_2025-02-14-17-59.json
@@ -0,0 +1,10 @@
+{
+  "changes": [
+    {
+      "packageName": "@itwin/core-common",
+      "comment": "Adding support for margins to `TextBlock`",
+      "type": "none"
+    }
+  ],
+  "packageName": "@itwin/core-common"
+}
\ No newline at end of file
diff --git a/core/backend/src/TextAnnotationGeometry.ts b/core/backend/src/TextAnnotationGeometry.ts
index 110a7bdd7257..24c2e5d80d80 100644
--- a/core/backend/src/TextAnnotationGeometry.ts
+++ b/core/backend/src/TextAnnotationGeometry.ts
@@ -61,7 +61,7 @@ function processTextRun(run: RunLayout, transform: Transform, context: GeometryC
   ts.transformInPlace(transform);
 
   setColor(run.style.color, context);
-  context.entries.push({ text: ts});
+  context.entries.push({ text: ts });
 }
 
 function createFractionTextString(text: string, run: RunLayout, origin: Point3d, transform: Transform): TextString {
@@ -121,7 +121,7 @@ function processFractionRun(run: RunLayout, transform: Transform, context: Geome
   });
 
   if (source.denominator.length > 0) {
-    context.entries.push({ text: createFractionTextString(source.denominator, run,denominatorOffset, transform) });
+    context.entries.push({ text: createFractionTextString(source.denominator, run, denominatorOffset, transform) });
   }
 }
 
@@ -146,28 +146,58 @@ function produceTextBlockGeometry(layout: TextBlockLayout, documentTransform: Tr
   }
 
   if (debugAnchorPt) {
-    // Draw lines representing the horizontal and vertical ranges, intersecting at the anchor point.
     context.entries.push({
-      color: ColorDef.red.toJSON(),
+      color: ColorDef.blue.toJSON()
     });
 
-    const lx = layout.range.low.x - debugAnchorPt.x;
-    const ly = layout.range.low.y - debugAnchorPt.y;
-    const hx = layout.range.high.x - debugAnchorPt.x;
-    const hy = layout.range.high.y - debugAnchorPt.y;
+    // Draw a blue box to show the element's margin
+    const marginCorners = layout.range.corners3d(true);
+    documentTransform.multiplyPoint3dArrayInPlace(marginCorners);
+    marginCorners.forEach((corner, index) => {
+      const next = marginCorners[index + 1];
+      if (!next) return;
+
+      context.entries.push({
+        separator: {
+          startPoint: [corner.x, corner.y, 0],
+          endPoint: [next.x, next.y, 0],
+        },
+      });
+    });
 
+    // Draw a blue x to show the anchor point - Rotation occurs around this point. The x will be 1 m by 1 m.
     context.entries.push({
       separator: {
-        startPoint: [lx, 0, 0],
-        endPoint: [hx, 0, 0],
+        startPoint: [debugAnchorPt.x - 1, debugAnchorPt.y - 1, 0],
+        endPoint: [debugAnchorPt.x + 1, debugAnchorPt.y + 1, 0],
       },
     });
+
     context.entries.push({
       separator: {
-        startPoint: [0, ly, 0],
-        endPoint: [0, hy, 0],
+        startPoint: [debugAnchorPt.x + 1, debugAnchorPt.y - 1, 0],
+        endPoint: [debugAnchorPt.x - 1, debugAnchorPt.y + 1, 0],
       },
     });
+
+    // Draw a red box to show the text range
+    context.entries.push({
+      color: ColorDef.red.toJSON(),
+    });
+
+    const rangeCorners = layout.textRange.corners3d(true);
+    documentTransform.multiplyPoint3dArrayInPlace(rangeCorners);
+    rangeCorners.forEach((corner, index) => {
+      const next = rangeCorners[index + 1];
+      if (!next) return;
+
+      context.entries.push({
+        separator: {
+          startPoint: [corner.x, corner.y, 0],
+          endPoint: [next.x, next.y, 0],
+        },
+      });
+    });
   }
 
   return { entries: context.entries };
@@ -207,6 +237,6 @@ export function produceTextAnnotationGeometry(args: ProduceTextAnnotationGeometr
   const dimensions = layout.range;
   const transform = args.annotation.computeTransform(dimensions);
 
-  const anchorPoint = args.debugAnchorPointAndRange ? args.annotation.computeAnchorPoint(dimensions) : undefined;
+  const anchorPoint = args.debugAnchorPointAndRange ? transform.multiplyPoint3d(args.annotation.computeAnchorPoint(dimensions)) : undefined;
   return produceTextBlockGeometry(layout, transform, anchorPoint);
 }
diff --git a/core/backend/src/TextAnnotationLayout.ts b/core/backend/src/TextAnnotationLayout.ts
index 72957ce2bb07..b21dfdbcd01b 100644
--- a/core/backend/src/TextAnnotationLayout.ts
+++ b/core/backend/src/TextAnnotationLayout.ts
@@ -6,7 +6,7 @@
  * @module ElementGeometry
  */
 
-import { BaselineShift, FontId, FontType, FractionRun, LineLayoutResult, Paragraph, Run, RunLayoutResult, TextBlock, TextBlockLayoutResult, TextRun, TextStyleSettings, TextStyleSettingsProps } from "@itwin/core-common";
+import { BaselineShift, FontId, FontType, FractionRun, LineLayoutResult, Paragraph, Run, RunLayoutResult, TextBlock, TextBlockLayoutResult, TextBlockMargins, TextRun, TextStyleSettings, TextStyleSettingsProps } from "@itwin/core-common";
 import { Geometry, Range2d } from "@itwin/core-geometry";
 import { IModelDb } from "./IModelDb";
 import { assert, NonFunctionPropertiesOf } from "@itwin/core-bentley";
@@ -496,6 +496,11 @@ export class LineLayout {
  */
 export class TextBlockLayout {
   public source: TextBlock;
+
+  /** @internal: This is primarily for debugging purposes. This is the range of text geometry */
+  public textRange = new Range2d();
+
+  /** The range including margins of the [[TextBlock]]. */
   public range = new Range2d();
   public lines: LineLayout[] = [];
   private _context: LayoutContext;
@@ -505,18 +510,19 @@ export class TextBlockLayout {
     this.source = source;
 
     if (source.width > 0) {
-      this.range.low.x = 0;
-      this.range.high.x = source.width;
+      this.textRange.low.x = 0;
+      this.textRange.high.x = source.width;
     }
 
     this.populateLines(context);
     this.justifyLines();
+    this.applyMargins(source.margins);
   }
 
   public toResult(): TextBlockLayoutResult {
     return {
       lines: this.lines.map((x) => x.toResult(this.source)),
-      range: this.range.toJSON(),
+      range: this.textRange.toJSON(),
     };
   }
 
@@ -607,8 +613,8 @@ export class TextBlockLayout {
 
     if (minOffset < 0) {
       // Shift left to accommodate lines that exceeded the document's minimum width.
-      this.range.low.x += minOffset;
-      this.range.high.x += minOffset;
+      this.textRange.low.x += minOffset;
+      this.textRange.high.x += minOffset;
     }
   }
 
@@ -643,9 +649,30 @@ export class TextBlockLayout {
     line.offsetFromDocument = lineOffset;
 
     // Update document range from computed line range and position
-    this.range.extendRange(line.range.cloneTranslated(lineOffset));
+    this.textRange.extendRange(line.range.cloneTranslated(lineOffset));
 
     this.lines.push(line);
     return new LineLayout(nextParagraph);
   }
+
+  private applyMargins(margins: TextBlockMargins) {
+    this.range = this.textRange.clone();
+
+    if (this.range.isNull)
+      return;
+
+    // Disregard negative margins.
+    const right = margins.right >= 0 ? margins.right : 0;
+    const left = margins.left >= 0 ? margins.left : 0;
+    const top = margins.top >= 0 ? margins.top : 0;
+    const bottom = margins.bottom >= 0 ? margins.bottom : 0;
+
+    const xHigh = this.textRange.high.x + right;
+    const yHigh = this.textRange.high.y + top;
+    const xLow = this.textRange.low.x - left;
+    const yLow = this.textRange.low.y - bottom;
+
+    this.range.extendXY(xHigh, yHigh);
+    this.range.extendXY(xLow, yLow);
+  }
 }
diff --git a/core/backend/src/test/annotations/TextAnnotation.test.ts b/core/backend/src/test/annotations/TextAnnotation.test.ts
index 76c205d47886..6e4e1e24a4e0 100644
--- a/core/backend/src/test/annotations/TextAnnotation.test.ts
+++ b/core/backend/src/test/annotations/TextAnnotation.test.ts
@@ -5,7 +5,7 @@
 import { assert, expect } from "chai";
 import { computeGraphemeOffsets, ComputeGraphemeOffsetsArgs, ComputeRangesForTextLayout, ComputeRangesForTextLayoutArgs, FindFontId, FindTextStyle, layoutTextBlock, LineLayout, RunLayout, TextBlockLayout, TextLayoutRanges } from "../../TextAnnotationLayout";
 import { Geometry, Range2d } from "@itwin/core-geometry";
-import { ColorDef,  FontType, FractionRun, LineBreakRun, LineLayoutResult, Run, RunLayoutResult, TextAnnotation, TextAnnotation2dProps, TextAnnotation3dProps, TextBlock, TextBlockGeometryPropsEntry, TextRun, TextStyleSettings } from "@itwin/core-common";
+import { ColorDef, FontType, FractionRun, LineBreakRun, LineLayoutResult, Run, RunLayoutResult, TextAnnotation, TextAnnotation2dProps, TextAnnotation3dProps, TextAnnotationAnchor, TextBlock, TextBlockGeometryPropsEntry, TextBlockMargins, TextRun, TextStringProps, TextStyleSettings } from "@itwin/core-common";
 import { IModelDb, SnapshotDb } from "../../IModelDb";
 import { TextAnnotation2d, TextAnnotation3d } from "../../TextAnnotationElement";
 import { produceTextAnnotationGeometry } from "../../TextAnnotationGeometry";
@@ -704,6 +704,48 @@ describe("layoutTextBlock", () => {
     }
   });
 
+  it("adds margins", function () {
+    const expectMargins = (layoutRange: Range2d, marginRange: Range2d, margins: Partial<TextBlockMargins>) => {
+      expect(marginRange.low.x).to.equal(layoutRange.low.x - (margins.left ?? 0));
+      expect(marginRange.high.x).to.equal(layoutRange.high.x + (margins.right ?? 0));
+      expect(marginRange.low.y).to.equal(layoutRange.low.y - (margins.bottom ?? 0));
+      expect(marginRange.high.y).to.equal(layoutRange.high.y + (margins.top ?? 0));
+    }
+
+    const makeTextBlock = (margins: Partial<TextBlockMargins>) => {
+      const textblock = TextBlock.create({ styleName: "", styleOverrides: { lineSpacingFactor: 0 }, margins });
+      textblock.appendRun(makeTextRun("abc"));
+      textblock.appendRun(makeTextRun("defg"));
+      return textblock;
+    }
+
+    let block = makeTextBlock({});
+    let layout = doLayout(block);
+
+    // Margins should be 0 by default
+    expect(layout.range.isAlmostEqual(layout.textRange)).to.be.true;
+    expectMargins(layout.textRange, layout.range, {});
+
+    // All margins should be applied to the range
+    block = makeTextBlock({ left: 1, right: 2, top: 3, bottom: 4 })
+    layout = doLayout(block);
+
+    expectMargins(layout.textRange, layout.range, { left: 1, right: 2, top: 3, bottom: 4 });
+
+    // Just horisontal margins should be applied
+    block = makeTextBlock({ left: 1, right: 2 });
+    layout = doLayout(block);
+
+    expectMargins(layout.textRange, layout.range, { left: 1, right: 2 });
+
+    // Just vertical margins should be applied
+    block = makeTextBlock({ top: 1, bottom: 2 });
+    layout = doLayout(block);
+
+    expectMargins(layout.textRange, layout.range, { top: 1, bottom: 2 });
+
+  });
+
   describe("grapheme offsets", () => {
     it("should return an empty array if source type is not text", function () {
       const textBlock = TextBlock.create({ styleName: "" });
@@ -1066,6 +1108,68 @@ describe("produceTextAnnotationGeometry", () => {
       "text",
     ]);
   });
+
+  it("offsets geometry entries by margins", () => {
+    function makeGeometryWithMargins(anchor: TextAnnotationAnchor, margins: TextBlockMargins): TextStringProps | undefined {
+      const runs = [makeText()];
+      const block = makeTextBlock(runs);
+      block.margins = margins;
+
+      const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+      annotation.anchor = anchor;
+
+      const geom = produceTextAnnotationGeometry({ iModel: mockIModel(), annotation }).entries;
+      return geom[1].text;
+    }
+
+    function testMargins(margins: TextBlockMargins, height: number, width: number) {
+      // We want to disregard negative margins. Note, I'm not changing the margins object itself. It gets passed into makeGeometryWithMargins as it is.
+      const left = margins.left >= 0 ? margins.left : 0;
+      const right = margins.right >= 0 ? margins.right : 0;
+      const top = margins.top >= 0 ? margins.top : 0;
+      const bottom = margins.bottom >= 0 ? margins.bottom : 0;
+
+      // Test case: bottom, left
+      let props = makeGeometryWithMargins({ horizontal: "left", vertical: "bottom" }, margins);
+      expect(props).not.to.be.undefined;
+      expect(props?.origin, "Expected geometry to be offset by left and bottom margins").to.deep.equal({ x: left, y: bottom, z: 0 });
+
+      // Test case: top, right
+      props = makeGeometryWithMargins({ vertical: "top", horizontal: "right" }, margins);
+
+      let x = (right + width) * -1;
+      let y = (top + height) * -1;
+      expect(props).not.to.be.undefined;
+      expect(props?.origin, "Expected geometry to be offset by top and right margins").to.deep.equal({ x, y, z: 0 });
+
+      // Test case: middle, center
+      props = makeGeometryWithMargins({ vertical: "middle", horizontal: "center" }, margins);
+
+      x = (left - right - width) / 2;
+      y = (bottom - top - height) / 2;
+      expect(props).not.to.be.undefined;
+      expect(props?.origin, "Expected geometry to be centered in the margins").to.deep.equal({ x, y, z: 0 });
+    }
+
+    // Getting the range from the same mock the native code uses to compute the range of a text block.
+    const textRange = mockIModel().computeRangesForText({
+      chars: "text",
+      bold: false,
+      italic: false,
+      fontId: 1,
+      widthFactor: 1,
+      lineHeight: 1,
+      baselineShift: "none",
+    });
+
+    const xLength = textRange.layout.xLength(); // Will be 1 because of the mock implementation.
+    const yLength = textRange.layout.yLength(); // Will be 1 because of the mock implementation.
+
+    testMargins({ top: 0, right: 0, bottom: 0, left: 0 }, yLength, xLength);
+    testMargins({ top: 1, right: 2, bottom: 3, left: 4 }, yLength, xLength);
+    testMargins({ top: -1, right: -2, bottom: -3, left: -4 }, yLength, xLength);
+  });
+
 });
 
 describe("TextAnnotation element", () => {
diff --git a/core/common/src/annotation/TextAnnotation.ts b/core/common/src/annotation/TextAnnotation.ts
index 4207a836bdc9..b8fb52965934 100644
--- a/core/common/src/annotation/TextAnnotation.ts
+++ b/core/common/src/annotation/TextAnnotation.ts
@@ -147,9 +147,10 @@ export class TextAnnotation {
    * The anchor point is computed as specified by this annotation's [[anchor]] setting. For example, if the text block is anchored
    * at the bottom left, then the transform will be relative to the bottom-left corner of `textBlockExtents`.
    * The text block will be rotated around the fixed anchor point according to [[orientation]], then translated by [[offset]].
-   * The anchor point will coincide with (0, 0, 0).
-   * @param boundingBox A box fully containing the [[textBlock]].
+   * The anchor point will coincide with (0, 0, 0) unless an [[offset]] is present.
+   * @param boundingBox A box fully containing the [[textBlock]]. This range should include the margins.
    * @see [[computeAnchorPoint]] to compute the transform's anchor point.
+   * @see [computeLayoutTextBlockResult]($backend) to lay out a `TextBlock`.
    */
   public computeTransform(boundingBox: Range2d): Transform {
     const anchorPt = this.computeAnchorPoint(boundingBox);
@@ -157,6 +158,7 @@ export class TextAnnotation {
 
     const rotation = Transform.createFixedPointAndMatrix(anchorPt, matrix);
     const translation = Transform.createTranslation(this.offset.minus(anchorPt));
+
     return translation.multiplyTransformTransform(rotation, rotation);
   }
 
diff --git a/core/common/src/annotation/TextBlock.ts b/core/common/src/annotation/TextBlock.ts
index 334d97d98088..5bbac9743071 100644
--- a/core/common/src/annotation/TextBlock.ts
+++ b/core/common/src/annotation/TextBlock.ts
@@ -411,6 +411,20 @@ export class Paragraph extends TextBlockComponent {
  */
 export type TextBlockJustification = "left" | "center" | "right";
 
+/** Describes the margins around the content inside a [[TextBlock]]. It's measured in meters.
+ * @beta
+ */
+export interface TextBlockMargins {
+  /** The left margin measured in meters. Must be a positive number >= 0. Negative values are disregarded */
+  left: number;
+  /** The right margin measured in meters. Must be a positive number >= 0. Negative values are disregarded */
+  right: number;
+  /** The top margin measured in meters. Must be a positive number >= 0. Negative values are disregarded */
+  top: number;
+  /** The bottom margin measured in meters. Must be a positive number >= 0. Negative values are disregarded */
+  bottom: number;
+};
+
 /** JSON representation of a [[TextBlock]].
  * @beta
  */
@@ -422,6 +436,8 @@ export interface TextBlockProps extends TextBlockComponentProps {
   width?: number;
   /** The alignment of the document content. Default: "left". */
   justification?: TextBlockJustification;
+  /** The margins to surround the document content. Default: 0 margins on all sides */
+  margins?: Partial<TextBlockMargins>;
   /** The paragraphs within the text block. Default: an empty array. */
   paragraphs?: ParagraphProps[];
 }
@@ -440,6 +456,8 @@ export class TextBlock extends TextBlockComponent {
   public width: number;
   /** The alignment of the document's content. */
   public justification: TextBlockJustification;
+  /** The margins of the document. */
+  public margins: TextBlockMargins;
   /** The ordered list of paragraphs within the document. */
   public readonly paragraphs: Paragraph[];
 
@@ -447,6 +465,15 @@ export class TextBlock extends TextBlockComponent {
     super(props);
     this.width = props.width ?? 0;
     this.justification = props.justification ?? "left";
+
+    // Assign default margins if not provided
+    this.margins = {
+      left: props.margins?.left ?? 0,
+      right: props.margins?.right ?? 0,
+      top: props.margins?.top ?? 0,
+      bottom: props.margins?.bottom ?? 0,
+    };
+
     this.paragraphs = props.paragraphs?.map((x) => Paragraph.create(x)) ?? [];
   }
 
@@ -455,6 +482,7 @@ export class TextBlock extends TextBlockComponent {
       ...super.toJSON(),
       width: this.width,
       justification: this.justification,
+      margins: this.margins,
       paragraphs: this.paragraphs.map((x) => x.toJSON()),
     };
   }
@@ -525,6 +553,12 @@ export class TextBlock extends TextBlockComponent {
       return false;
     }
 
+    const marginsAreEqual = Object.entries(this.margins).every(([key, value]) =>
+      value === (other.margins as any)[key]
+    );
+
+    if (!marginsAreEqual) return false;
+
     return this.paragraphs.every((paragraph, index) => paragraph.equals(other.paragraphs[index]));
   }
 }
diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md
index 9599f6deb596..9bef9d42d16e 100644
--- a/docs/changehistory/NextVersion.md
+++ b/docs/changehistory/NextVersion.md
@@ -11,6 +11,7 @@ Table of contents:
   - [Font APIs](#font-apis)
   - [Geometry](#geometry)
     - [Polyface Traversal](#polyface-traversal)
+    - [Text Block Margins](#text-Block-margins)
   - [Display](#graphics)
     - [Read Image To Canvas](#read-image-to-canvas)
   - [Back-end image conversion](#back-end-image-conversion)
@@ -90,6 +91,10 @@ The new class [IndexedPolyfaceWalker]($core-geometry) has methods to complete th
 
 If a walker operation would advance outside the mesh (e.g., `edgeMate` of a boundary edge), it returns an invalid walker.
 
+### Text Block Margins
+
+You can now surround a [TextBlock]($core-common) with padding by setting its [TextBlockMargins]($core-common). When [layoutTextBlock]($core-backend) computes [TextBlockLayout.range]($core-backend), it will expand the bounding box to include the margins. [ProduceTextAnnotationGeometryArgs.debugAnchorPointAndRange]($core-backend) now produces two bounding boxes: one tightly fitted to the text, and a second expanded to include the margins.
+
 ## Display
 
 ### Read image to canvas
diff --git a/test-apps/display-test-app/src/frontend/TextDecoration.ts b/test-apps/display-test-app/src/frontend/TextDecoration.ts
index 269a4d8793ec..5a197bb40dd9 100644
--- a/test-apps/display-test-app/src/frontend/TextDecoration.ts
+++ b/test-apps/display-test-app/src/frontend/TextDecoration.ts
@@ -3,12 +3,15 @@
 * See LICENSE.md in the project root for license terms and full copyright notice.
 *--------------------------------------------------------------------------------------------*/
 
-import { BaselineShift, ColorDef, FractionRun, GeometryStreamBuilder, IModelTileRpcInterface, LineBreakRun, TextAnnotation, TextAnnotationAnchor, TextBlock, TextBlockJustification, TextRun, TextStyleSettingsProps } from "@itwin/core-common";
+import { BaselineShift, ColorDef, FractionRun, GeometryStreamBuilder, IModelTileRpcInterface, LineBreakRun, TextAnnotation, TextAnnotationAnchor, TextBlock, TextBlockJustification, TextBlockMargins, TextRun, TextStyleSettingsProps } from "@itwin/core-common";
 import { DecorateContext, Decorator, GraphicType, IModelApp, IModelConnection, readElementGraphics, RenderGraphicOwner, Tool } from "@itwin/core-frontend";
 import { DtaRpcInterface } from "../common/DtaRpcInterface";
 import { Guid, Id64, Id64String } from "@itwin/core-bentley";
 import { Point3d, YawPitchRollAngles } from "@itwin/core-geometry";
 
+// Ignoring the spelling of the keyins. They're case insensitive, so we check against lowercase.
+// cspell:ignore superscript, subscript, widthfactor, fractionscale, fractiontype
+
 class TextEditor implements Decorator {
   // Geometry properties
   private _categoryId: Id64String = Id64.invalid;
@@ -97,6 +100,15 @@ class TextEditor implements Decorator {
     this._textBlock.justification = justification;
   }
 
+  public setMargins(margins: Partial<TextBlockMargins>): void {
+    this._textBlock.margins = {
+      left: margins.left ?? this._textBlock.margins.left,
+      right: margins.right ?? this._textBlock.margins.right,
+      top: margins.top ?? this._textBlock.margins.top,
+      bottom: margins.bottom ?? this._textBlock.margins.bottom,
+    };
+  }
+
   public async update(): Promise<void> {
     if (!this._iModel) {
       throw new Error("Invoke `dta text init` first");
@@ -169,7 +181,13 @@ export class TextDecorationTool extends Tool {
         editor.clear();
         return true;
       case "init":
-        editor.init(vp.iModel, arg);
+        // Use the first category if the user doesn't specify one. This is just a convenience.
+        const category = arg ?? vp.view.categorySelector.categories.values().next().value;
+        if (undefined === category || category === "") {
+          throw new Error("No category provided.");
+        }
+
+        editor.init(vp.iModel, category);
         break;
       case "center":
         editor.origin = vp.view.getCenter();
@@ -287,6 +305,34 @@ export class TextDecorationTool extends Tool {
         }
         break;
       }
+      case "margin": {
+        const marginLocation = inArgs[1].toLowerCase();
+        const val = Number(inArgs[2]);
+        if (isNaN(val)) {
+          throw new Error("Expected margin location followed by a number. Margin location can be left, right, top, bottom, all, horizontal, or vertical");
+        }
+
+        switch (marginLocation) {
+          case "left":
+          case "right":
+          case "top":
+          case "bottom":
+            editor.setMargins({ [marginLocation]: val });
+            break;
+          case "all":
+            editor.setMargins({ left: val, right: val, top: val, bottom: val });
+            break;
+          case "horizontal":
+            editor.setMargins({ left: val, right: val });
+            break;
+          case "vertical":
+            editor.setMargins({ top: val, bottom: val });
+            break;
+          default:
+            throw new Error("Expected left, right, top, bottom, all, horizontal, or vertical");
+        }
+        break;
+      }
       case "debug": {
         editor.debugAnchorPointAndRange = !editor.debugAnchorPointAndRange;
         break;


Reviews:
user: login: tcobbs-bentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-15T00:50:27Z
user: login: tcobbs-bentley
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-15T00:51:16Z
user: login: tcobbs-bentley
state: APPROVED
author_association: MEMBER
submitted_at: 2025-02-15T00:56:05Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-19T19:35:39Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-19T19:46:25Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-19T21:10:19Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-19T21:11:30Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-19T21:18:23Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-19T21:36:16Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-19T21:40:13Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-19T21:43:08Z
user: login: pmconne
body: Need tests that verify that the generated TextString geometry is offset appropriately based on the margins.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-02-21T15:27:31Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T18:08:12Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T18:11:28Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T18:16:26Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:36:45Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:36:53Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:37:02Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T19:51:24Z
user: login: pmconne
body: Approving with comments, please also update NextVersion.md.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-12T19:52:40Z
user: login: claudiaareneee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:44:42Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:41:58Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:42:38Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:45:47Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:53:01Z
user: login: pmconne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:53:09Z

Review comments:
diff_hunk: @@ -422,6 +436,8 @@ export interface TextBlockProps extends TextBlockComponentProps {
   width?: number;
   /** The alignment of the document content. Default: "left". */
   justification?: TextBlockJustification;
+  /** The margins to surround the document content. Default: 0 margins on each sides */
path: core/common/src/annotation/TextBlock.ts
user: login: tcobbs-bentley
body: ```suggestion
  /** The margins to surround the document content. Default: 0 margins on all sides */
```
created_at: 2025-02-15T00:50:27Z
author_association: MEMBER
diff_hunk: @@ -422,6 +436,8 @@ export interface TextBlockProps extends TextBlockComponentProps {
   width?: number;
   /** The alignment of the document content. Default: "left". */
   justification?: TextBlockJustification;
+  /** The margins to surround the document content. Default: 0 margins on each sides */
path: core/common/src/annotation/TextBlock.ts
user: login: tcobbs-bentley
body: This could also be "0 margin on each side".
created_at: 2025-02-15T00:51:16Z
author_association: MEMBER
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: claudiaareneee
body: Broken test I mentioned. 
created_at: 2025-02-19T19:35:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: pmconne
body: > it doesn't match the original range

By "original range" are you referring to this null range?
> `JSON: {"low":[1e+200,1e+200,1e+200],"high":[-1e+200,-1e+200,-1e+200]}`

created_at: 2025-02-19T19:46:25Z
author_association: MEMBER
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: claudiaareneee
body: I mean that the `el1` range: `JSON: {"low":[-2.766357421875,-1,0],"high":[2.766357421875,1,0]}` does not match the `TextBlockLayout` range: `JSON: [[0,-1],[7,0]]`

I suppose the el0 range `isNull` because it hasn't been inserted into the model yet?

created_at: 2025-02-19T21:10:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: claudiaareneee
body: (I think I was calling the original range the `TextBlockLayout` range computed from `doLayout`)
created_at: 2025-02-19T21:11:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: pmconne
body: > I suppose the el0 range isNull because it hasn't been inserted into the model yet?

Right.

> the el1 range: JSON: {"low":[-2.766357421875,-1,0],"high":[2.766357421875,1,0]} does not match the TextBlockLayout range: JSON: [[0,-1],[7,0]]

Thanks for clarifying. The element's bounding box is computed from the bounding boxes of the `TextString`(s) inserted into its geometry stream, which are created from the `TextBlock`. Some [padding is added](https://github.com/iTwin/imodel-native/blob/5bae73c8872eea691dc6e1b36d0935293c366305/iModelCore/iModelPlatform/DgnCore/ElementGeometry.cpp#L313).
created_at: 2025-02-19T21:18:23Z
author_association: MEMBER
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: claudiaareneee
body: Okay, then this is as expected. I wasn't sure. So should bounding box be specifically for the geometry actually produced, and if the user wants something more explicit, use the `TextBlockLayout.range` or `TextBlockLayout.marginRange` depending on use-case? 

If so, I can leave a comment somewhere about that and delete this test.  
created_at: 2025-02-19T21:36:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: pmconne
body: > should bounding box be specifically for the geometry actually produced

Yes. If at some point you start adding boxes or other kinds of frames, those should produce additional geometry that would expand the element's bounding box, accounting for the margins. When you're only producing `TextString` geometry, the size of the bounding box should be indifferent to the margins. The offsets of the `TextString`s as computed during layout would be affected by the margins.
created_at: 2025-02-19T21:40:13Z
author_association: MEMBER
diff_hunk: @@ -1241,5 +1283,77 @@ describe("TextAnnotation element", () => {
       test(TextAnnotation.fromJSON({ textBlock: { styleName: "block" } }));
       test(createAnnotation());
     });
+
+    it("includes margins inside bounding box", () => {
+      const logRange = (name: string, range: Range2d | Range3d) => {
+        console.log(name, ": \n\tJSON:", JSON.stringify(range.toJSON(), undefined, ""), "\n\txLength:", range.xLength(), "\n\tyLength:", range.yLength(), "\n\n");
+      }
+
+      const makeTextBlock = (props: TextBlockProps) => {
+        const textblock = TextBlock.create({ styleOverrides: { lineSpacingFactor: 0 }, ...props });
+        textblock.appendRun(makeTextRun("abc"));
+        textblock.appendRun(makeTextRun("defg"));
+        return textblock;
+      }
+
+      const getTransform = (origin: XYZ, range: Range2d, textAnnotation: TextAnnotation): Transform => {
+        // Compute the transform that positions and orients this annotation relative to its anchor point. Includes its rotation
+        const relativeTransform = textAnnotation.computeTransform(range);
+
+        // Now scoot that relative transform to origin
+        const origin3d = origin
+        const transform = Transform.createTranslation(origin3d);
+        return transform.multiplyTransformTransform(relativeTransform);
+      }
+
+      const test = (props: TextBlockProps) => {
+        // Create textblock
+        const block = makeTextBlock(props);
+
+        // Create annotation
+        const annotation = TextAnnotation.fromJSON({ textBlock: block.toJSON() });
+        annotation.anchor = { vertical: "middle", horizontal: "center" };
+        const el0 = createElement();
+        el0.setAnnotation(annotation);
+
+        // Insert element
+        const elId = el0.insert();
+
+        // Get element
+        const el1 = imodel.elements.getElement<TextAnnotation3d>(elId);
+        const anno = el1.getAnnotation();
+        const newTextBlock = anno?.textBlock;
+        expect(newTextBlock).not.to.be.undefined;
+
+        // do layout on annotation1 and annotation2
+        const newLayout = doLayout(newTextBlock!);
+        const transform = getTransform(el1.placement.origin, newLayout.range, anno!);
+
+        // Dubugging the ranges
+        logRange("el0 bbox", el0.placement.bbox);
+        logRange("el1 bbox", el1.placement.bbox);
+        logRange("newLayout.range", newLayout.range);
+        logRange("transformed newLayout.range", transform.multiplyRange(Range3d.fromJSON(newLayout.range)));
+        // logRange("newLayout.marginRange", newLayout.marginRange);
+        // logRange("transformed newLayout.marginRange", transform.multiplyRange(Range3d.fromJSON(newLayout.marginRange)));
+
+        // Check that the bounding box is correct
+
+        // Before my changes, this test should pass. After my changes, it should fail.
+        const range3d = Range3d.fromJSON(newLayout.range);
+        expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+
+        // After my changes, this should pass instead.
+        // const range3d = Range3d.fromJSON(newLayout.marginRange);
+        // expect(el1.placement.bbox.isAlmostEqual(range3d)).to.be.true;
+      };
+
+      // 0 margins
path: core/backend/src/test/annotations/TextAnnotation.test.ts
user: login: claudiaareneee
body: Okay, sounds good. Whenever I do that, I can add these tests back for that specific use case. As it is, I'll remove this test for now.
created_at: 2025-02-19T21:43:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -411,6 +411,20 @@ export class Paragraph extends TextBlockComponent {
  */
 export type TextBlockJustification = "left" | "center" | "right";
 
+/** Describes the margins around the content inside a [[TextBlock]]. It's measured in meters.
+ * @beta
+ */
+export interface TextBlockMargins {
+  /** The left margin. Must be a positive integer >= 0. Negative values are disregarded */
path: core/common/src/annotation/TextBlock.ts
user: login: pmconne
body: It's measured in meters, it doesn't need to be an integer.
created_at: 2025-03-11T18:08:12Z
author_association: MEMBER
diff_hunk: @@ -440,13 +456,17 @@ export class TextBlock extends TextBlockComponent {
   public width: number;
   /** The alignment of the document's content. */
   public justification: TextBlockJustification;
+  /** The margins of the document. */
+  public margins: TextBlockMargins;
   /** The ordered list of paragraphs within the document. */
   public readonly paragraphs: Paragraph[];
 
   private constructor(props: TextBlockProps) {
     super(props);
     this.width = props.width ?? 0;
     this.justification = props.justification ?? "left";
+    // Assign default margins if not provided
+    this.margins = { left: 0, right: 0, top: 0, bottom: 0, ...props.margins };
path: core/common/src/annotation/TextBlock.ts
user: login: pmconne
body: ```suggestion
    this.margins = {
      left: props.margins.left ?? 0,
      right: props.margins.right ?? 0,
      top: props.margins.top ?? 0,
      bottom: props.margins.bottom ?? 0,
    };
```

If `props` is, e.g., `{ top: undefined }` then the spread operator will overwrite your `0` with `undefined` in `this.margins`. [Example](https://www.typescriptlang.org/play/?#code/JYOwLgpgTgZghgYwgAgCoAtQHNkG9khwC2EAXMgM5hTbIC+AsAFDMwCuICYwA9iMkTgBrCBmwAKAA5QekiuQAKcKNzgAbADxiQWAHwBKctpy5myc8igQwbKP3yES5AEQATCPDZqwzgDTIAOiDpWQp6AG5mRhYmBD4qZDBMHWQAXgFhUWSscQdiMmQOdxhQCFd6fUjY+J41CAC1Hhyk7ADHCErmOJAKWvrG5uy2-IaIHSTK5AB6KeQ4oklgOqhE5KEwlp1hkjm4EBAeMGQAIxQij1Ly47Yj4FuKIA). Also, spread operator will copy any other properties in `props` which will break your `marginsAreEqual` function below.
created_at: 2025-03-11T18:11:28Z
author_association: MEMBER
diff_hunk: @@ -97,6 +97,10 @@ class TextEditor implements Decorator {
     this._textBlock.justification = justification;
   }
 
+  public setMargins(margins: Partial<TextBlockMargins>): void {
+    this._textBlock.margins = { ...this._textBlock.margins, ...margins };
path: test-apps/display-test-app/src/frontend/TextDecoration.ts
user: login: pmconne
body: Spread operator problem again.
created_at: 2025-03-11T18:16:26Z
author_association: MEMBER
diff_hunk: @@ -97,6 +97,10 @@ class TextEditor implements Decorator {
     this._textBlock.justification = justification;
   }
 
+  public setMargins(margins: Partial<TextBlockMargins>): void {
+    this._textBlock.margins = { ...this._textBlock.margins, ...margins };
path: test-apps/display-test-app/src/frontend/TextDecoration.ts
user: login: claudiaareneee
body: Should be fixed now
created_at: 2025-03-11T20:36:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -440,13 +456,17 @@ export class TextBlock extends TextBlockComponent {
   public width: number;
   /** The alignment of the document's content. */
   public justification: TextBlockJustification;
+  /** The margins of the document. */
+  public margins: TextBlockMargins;
   /** The ordered list of paragraphs within the document. */
   public readonly paragraphs: Paragraph[];
 
   private constructor(props: TextBlockProps) {
     super(props);
     this.width = props.width ?? 0;
     this.justification = props.justification ?? "left";
+    // Assign default margins if not provided
+    this.margins = { left: 0, right: 0, top: 0, bottom: 0, ...props.margins };
path: core/common/src/annotation/TextBlock.ts
user: login: claudiaareneee
body: Should be fixed now
created_at: 2025-03-11T20:36:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -411,6 +411,20 @@ export class Paragraph extends TextBlockComponent {
  */
 export type TextBlockJustification = "left" | "center" | "right";
 
+/** Describes the margins around the content inside a [[TextBlock]]. It's measured in meters.
+ * @beta
+ */
+export interface TextBlockMargins {
+  /** The left margin. Must be a positive integer >= 0. Negative values are disregarded */
path: core/common/src/annotation/TextBlock.ts
user: login: claudiaareneee
body: Should be fixed now
created_at: 2025-03-11T20:37:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -497,6 +497,7 @@ export class LineLayout {
 export class TextBlockLayout {
   public source: TextBlock;
   public range = new Range2d();
+  public marginRange = new Range2d();
path: core/backend/src/TextAnnotationLayout.ts
user: login: pmconne
body: Is the distinction between `marginRange` and `range` useful for anything besides tests and debug graphics? If both are needed, I'd think `range` should include the margins and `textRange` should be the range without the margins. But it'd be even clearer if the API simply exposed a single bounding box that includes the margins. You could of course keep a second `@internal` range for debugging purposes but as a user of the API I don't want to have to wonder which of the two bounding boxes I should use in a given context.
created_at: 2025-03-12T19:51:24Z
author_association: MEMBER
diff_hunk: @@ -497,6 +497,7 @@ export class LineLayout {
 export class TextBlockLayout {
   public source: TextBlock;
   public range = new Range2d();
+  public marginRange = new Range2d();
path: core/backend/src/TextAnnotationLayout.ts
user: login: claudiaareneee
body: Yeah, that makes more sense. I'll do that.
created_at: 2025-03-12T20:44:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,6 +91,16 @@ The new class [IndexedPolyfaceWalker]($core-geometry) has methods to complete th
 
 If a walker operation would advance outside the mesh (e.g., `edgeMate` of a boundary edge), it returns an invalid walker.
 
+### Text Block Margins
+
+A new enhancement has been made to the [TextBlock]($core-common) class to add support for margins. These margins are in meters, and are specified by using the [TextBlockMargin]($core-common) interface.
+
+The margin range is calculated in [TextAnnotationLayout.layoutTextBlock]($core-backend) and saved to the [TextBlockLayout.range]($core-backend) property. The range of just the text itself is stored in a property called [TextBlockLayout.textRange]($core-backend). In general, use of `range` is preferred over `textRange`.
path: docs/changehistory/NextVersion.md
user: login: pmconne
body: `textRange` is `@internal`, it should not be mentioned in public documentation.
created_at: 2025-03-14T16:41:58Z
author_association: MEMBER
diff_hunk: @@ -90,6 +91,16 @@ The new class [IndexedPolyfaceWalker]($core-geometry) has methods to complete th
 
 If a walker operation would advance outside the mesh (e.g., `edgeMate` of a boundary edge), it returns an invalid walker.
 
+### Text Block Margins
+
+A new enhancement has been made to the [TextBlock]($core-common) class to add support for margins. These margins are in meters, and are specified by using the [TextBlockMargin]($core-common) interface.
+
+The margin range is calculated in [TextAnnotationLayout.layoutTextBlock]($core-backend) and saved to the [TextBlockLayout.range]($core-backend) property. The range of just the text itself is stored in a property called [TextBlockLayout.textRange]($core-backend). In general, use of `range` is preferred over `textRange`.
+
+The [TextBlockLayout.range]($core-backend) property will be passed into the [TextAnnotation.computeTransform]($core-common) method when producing the geometry. This will position the anchor point of the text relative to the [TextBlockLayout.range]($core-backend) property.
+
+When `debugAnchorPointAndRange` is true in [TextAnnotationGeometry.produceTextAnnotationGeometry]($core-backend), both ranges (margin and text) will be drawn in the geometry along with the anchor point.
path: docs/changehistory/NextVersion.md
user: login: pmconne
body: `produceTextAnnotationGeometry` is a free function, it's not inside a `TextAnnotationGeometry` object. @ben-polinsky why isn't that producing a docs build error?

I will rewrite the NextVersion.md docs.
created_at: 2025-03-14T16:42:38Z
author_association: MEMBER
diff_hunk: @@ -90,6 +91,16 @@ The new class [IndexedPolyfaceWalker]($core-geometry) has methods to complete th
 
 If a walker operation would advance outside the mesh (e.g., `edgeMate` of a boundary edge), it returns an invalid walker.
 
+### Text Block Margins
+
+A new enhancement has been made to the [TextBlock]($core-common) class to add support for margins. These margins are in meters, and are specified by using the [TextBlockMargin]($core-common) interface.
+
+The margin range is calculated in [TextAnnotationLayout.layoutTextBlock]($core-backend) and saved to the [TextBlockLayout.range]($core-backend) property. The range of just the text itself is stored in a property called [TextBlockLayout.textRange]($core-backend). In general, use of `range` is preferred over `textRange`.
path: docs/changehistory/NextVersion.md
user: login: pmconne
body: @ben-polinsky no docs build error despite no function called `TextAnnotationLayout.layoutTextBlock`. Is this some feature I'm not familiar with where you use the filename sans extension to disambiguate a free function's name?
created_at: 2025-03-14T16:45:47Z
author_association: MEMBER
diff_hunk: @@ -90,6 +91,16 @@ The new class [IndexedPolyfaceWalker]($core-geometry) has methods to complete th
 
 If a walker operation would advance outside the mesh (e.g., `edgeMate` of a boundary edge), it returns an invalid walker.
 
+### Text Block Margins
+
+A new enhancement has been made to the [TextBlock]($core-common) class to add support for margins. These margins are in meters, and are specified by using the [TextBlockMargin]($core-common) interface.
+
+The margin range is calculated in [TextAnnotationLayout.layoutTextBlock]($core-backend) and saved to the [TextBlockLayout.range]($core-backend) property. The range of just the text itself is stored in a property called [TextBlockLayout.textRange]($core-backend). In general, use of `range` is preferred over `textRange`.
+
+The [TextBlockLayout.range]($core-backend) property will be passed into the [TextAnnotation.computeTransform]($core-common) method when producing the geometry. This will position the anchor point of the text relative to the [TextBlockLayout.range]($core-backend) property.
+
+When `debugAnchorPointAndRange` is true in [TextAnnotationGeometry.produceTextAnnotationGeometry]($core-backend), both ranges (margin and text) will be drawn in the geometry along with the anchor point.
path: docs/changehistory/NextVersion.md
user: login: pmconne
body: Resolving, but question for @ben-polinsky remains.
created_at: 2025-03-14T16:53:00Z
author_association: MEMBER
diff_hunk: @@ -90,6 +91,16 @@ The new class [IndexedPolyfaceWalker]($core-geometry) has methods to complete th
 
 If a walker operation would advance outside the mesh (e.g., `edgeMate` of a boundary edge), it returns an invalid walker.
 
+### Text Block Margins
+
+A new enhancement has been made to the [TextBlock]($core-common) class to add support for margins. These margins are in meters, and are specified by using the [TextBlockMargin]($core-common) interface.
+
+The margin range is calculated in [TextAnnotationLayout.layoutTextBlock]($core-backend) and saved to the [TextBlockLayout.range]($core-backend) property. The range of just the text itself is stored in a property called [TextBlockLayout.textRange]($core-backend). In general, use of `range` is preferred over `textRange`.
path: docs/changehistory/NextVersion.md
user: login: pmconne
body: Resolving, but question for @ben-polinsky remains.
created_at: 2025-03-14T16:53:09Z
author_association: MEMBER

PR comments:
user: login: pmconne
created_at: 2025-02-17T12:56:54Z
body: Two questions.

1. How do the margins affect the bounding box of a text annotation element?

2. What happens when the user provides a negative margin value?
author_association: MEMBER
user: login: claudiaareneee
created_at: 2025-02-17T14:37:06Z
body: > Two questions.
> 
> 1. How do the margins affect the bounding box of a text annotation element?
> 2. What happens when the user provides a negative margin value?

1. Great question. I hadn't considered this. I don't think it's changing the bounding box right now, but I could be wrong. I think the bounding box should include the margins. Let me test and probably fix.
2. The margin would be inset.

As a note, right now margins don't actually do much, but I'll want them for #7677. 
author_association: CONTRIBUTOR
user: login: claudiaareneee
created_at: 2025-02-19T19:31:54Z
body: @pmconne, to answer question 1, It should take the margins into account and be included in the bounding box. Right now, because I haven't made changes to it, I expect it to match the existing range.

I wrote a test to see what happens. I had some unexpected results. Not only does the bbox of the geometric element not match my new range, it doesn't match the original range. I wondered if there was some transformation or different geometry space that were being calculated. 

In my test, I console log these values. This is what I get. 

Note, `el1`, the element created,  xLength is 5.53271484375 when I expect it to be 7. yLength is 2 when I expect it to be 1. 

```tsx

TextAnnotation element
    persistence
Error | ECDb | ECClass 'bis.ChannelRootAspect' does not exist or could not be loaded. 
el0 bbox : 
        JSON: {"low":[1e+200,1e+200,1e+200],"high":[-1e+200,-1e+200,-1e+200]} 
        xLength: 0 
        yLength: 0 


el1 bbox : 
        JSON: {"low":[-2.766357421875,-1,0],"high":[2.766357421875,1,0]} 
        xLength: 5.53271484375 
        yLength: 2 


newLayout.range : 
        JSON: [[0,-1],[7,0]] 
        xLength: 7 
        yLength: 1 


transformed newLayout.range : 
        JSON: {"low":[-3.5,-0.5,0],"high":[3.5,0.5,0]} 
        xLength: 7 
        yLength: 1 
```

For my test see the next comment
author_association: CONTRIBUTOR
user: login: claudiaareneee
created_at: 2025-02-24T17:40:40Z
body: > Need tests that verify that the generated TextString geometry is offset appropriately based on the margins.

You're right. My current implementation doesn't move the text at all which is kind of the whole point. I'll add that. For clarification, this is what you mean right?

![comment](https://github.com/user-attachments/assets/ef1cf654-af0f-4227-9301-f226ec260a8e)

For reference, this is what HTML does:
![comment 2](https://github.com/user-attachments/assets/77abff7b-e563-4ca3-a0f5-684a3f64dc7f)
author_association: CONTRIBUTOR
user: login: pmconne
created_at: 2025-02-25T14:28:58Z
body: > this is what you mean right?

Yes.

> For reference, this is what HTML does:

Do you intend to implement margin, border, and/or padding similarly to HTML?

I have been interpreting your "margin" concept as analogous to HTML's "padding" concept.
The HTML "margin" concept doesn't make sense for stand-alone text annotations (e.g., text elements), it only makes sense when there's layout happening with multiple annotations (e.g., a cell in a text table, maybe, but even then the cell already has a border shared with adjacent cells so only padding inside the border makes sense).
author_association: MEMBER
user: login: claudiaareneee
created_at: 2025-02-25T16:38:25Z
body: I've also been treating it similar to padding, so we're on the same page there.
author_association: CONTRIBUTOR
user: login: claudiaareneee
created_at: 2025-03-05T16:46:48Z
body: Alright, @pmconne, I want your input/advice on some changes.

So I've implemented the offset we've talked about, but I want to clarify some behavior. This is what I've got so far. 

Notes:

- $\color{blue}{\textsf{✚}}$ represents the element origin at relative (0, 0)
- $\color{blue}{\textsf{☐}}$ represents margin range
- $\color{red}{\textsf{✕}}$ represents anchor point
  - `anchor` being the value calculated by `TextAnnotation.computeAnchorPoint` 
- $\color{red}{\textsf{☐}}$ represents text range

For all the screenshots below, I'm using the following margins:
```ts
margins = {
  right: 4,
  left: 2,
  top: 1,
  bottom: 4
};
```

### For non-center/non-middle alignments

The element’s origin ($\color{blue}{\textsf{✚}}$) is on the margin range ($\color{blue}{\textsf{☐}}$).
The text’s anchor ($\color{red}{\textsf{✕}}$) is on the text range ($\color{red}{\textsf{☐}}$) .

<img width="613" alt="image" src="https://github.com/user-attachments/assets/150fb5b2-54e1-49ad-97f3-9f03a16cd935" />

```ts
anchor = {
  vertical: "bottom",
  horizontal: "right" 
};
```

### For center/middle alignments

The element’s origin ($\color{blue}{\textsf{✚}}$) is in the center of the margin range ($\color{blue}{\textsf{☐}}$).
The text’s anchor ($\color{red}{\textsf{✕}}$) is in the center of the text range ($\color{red}{\textsf{☐}}$) .

<img width="613" alt="image" src="https://github.com/user-attachments/assets/eb1ea3e8-382f-43b6-b475-71509f907694" />

```ts
anchor = {
  vertical: "middle",
  horizontal: "center" 
};
```

### With an offset

The element’s origin ($\color{blue}{\textsf{✚}}$) is added to the offset produced by the margin range($\color{blue}{\textsf{☐}}$).
The text’s anchor ($\color{red}{\textsf{✕}}$) is still on the text range ($\color{red}{\textsf{☐}}$) .

<img width="613" alt="image" src="https://github.com/user-attachments/assets/202f3473-b5ef-4217-a70f-2fbd6e6cbc48" />

```ts
anchor = {
  vertical: "top",
  horizontal: "left" 
};

offset = { x: 3, y: -3 }
```

### Rotation

Rotation is adjusted to include the offset produced by the margins. Note that the rotation is around the center of the margin range (because we have no offset, this is at `(0, 0)` or $\color{blue}{\textsf{✚}}$), and not the text anchor point ($\color{red}{\textsf{✕}}$). 

<img width="613" alt="image" src="https://github.com/user-attachments/assets/a480bf61-fa56-4a37-8936-238c26b908c4" />

```ts
anchor = {
  vertical: "middle",
  horizontal: "center" 
};

offset = { x: 0, y: 0 }
```

### Discussion

I believe this behavior is correct for rotation and translation. The question I have is more related to verbage. Should `TextAnnotation.computeAnchorPoint` be adjusted to include the margin offset? Does this behavior make sense to you?
author_association: CONTRIBUTOR
user: login: pmconne
created_at: 2025-03-05T19:07:43Z
body: > I believe this behavior is correct for rotation and translation.

Can you explain why you believe this?

To me it seems you've defined the margins as part of the text block. Therefore the text block's bounding box includes any extra padding added by margins. It's confusing if the anchor point is based on a second bounding box that excludes the margins. It also doesn't seem helpful, especially if in future you add (as you've indicated you want to) a visible border which presumably would encompass the padding (so, equal to the full bounding box). Example: I'd expect rotation about a middle-left anchor point to pivot about a point on the left edge of the full bounding box (top), not the text's inner range (bottom).

![image](https://github.com/user-attachments/assets/55d8f49e-541a-431b-b6ea-92a2e166c573)


author_association: MEMBER
user: login: claudiaareneee
created_at: 2025-03-06T15:09:10Z
body: Right, your top version is how I have it implemented. 

I agree that that the pivot point should include the padding of the margins. Basically, the `anchor` point as it's calculated by `calculateAnchorPoint` does not include the margin offset. We are still accounting for the margins when rotating the text. 

It wouldn't change any behavior to add them, and I think that they should. 

So should `calculateAnchorPoint` include the offset produced by the margins? (I think it should) 

Current behavior:


https://github.com/user-attachments/assets/43246ce9-d69f-4288-9e0d-b8b4ff05d1df

author_association: CONTRIBUTOR
user: login: pmconne
created_at: 2025-03-11T18:22:04Z
body: > I agree that that the pivot point should include the padding of the margins. Basically, the anchor point as it's calculated by calculateAnchorPoint does not include the margin offset.

The anchor point **is** the pivot point. This is where I am becoming confused.
author_association: MEMBER
user: login: claudiaareneee
created_at: 2025-03-11T20:38:04Z
body: > > I agree that that the pivot point should include the padding of the margins. Basically, the anchor point as it's calculated by calculateAnchorPoint does not include the margin offset.
> 
> The anchor point **is** the pivot point. This is where I am becoming confused.

Okay, I changed the code to represent that. I need to fix/write the tests though, then I'll publish my pr
author_association: CONTRIBUTOR
user: login: claudiaareneee
created_at: 2025-03-12T17:00:47Z
body: Okay, @pmconne, ready for a rereview
author_association: CONTRIBUTOR
user: login: claudiaareneee
created_at: 2025-03-14T15:42:55Z
body: > Approving with comments, please also update NextVersion.md.

Okay, all updates should be made. Do you want to resolve your comments?
author_association: CONTRIBUTOR