12

I need to know the exact bounds a piece of text -- the equivalent of getTextBounds for Android. I realize this goes somewhat counter to Flutter's design, but I am using text in a non-traditional way (as if the text were, say, embedded into an artistic picture at a precise location and size).

I've tried three methods:

  • TextPainter's minIntrinsicWidth and height. Source below. This produces a bounding box with space on all sides:

    enter image description here

    I need the sides of that rectangle to be right up against the black pixels of the '8'. (In this particular case, width and maxIntrinsicWidth give the same value as minIntrinsicWidth; also, preferredLineHeight gives the same value as height.)

  • Paragraph's getBoxesForRange -- basically the same result as with TextPainter.

  • FittedBox -- also leaves spaces, no matter the BoxFit enum value. I used this answer as a starting point.

TextPainter code follows. (I realize this is inefficient; I don't care until I get the needed bounding box. I used Scene/Picture/etc in hopes of finding appropriate functionality in the lower levels.)

import 'dart:ui';
import 'dart:typed_data';
import 'package:flutter/painting.dart';

const black = Color(0xff000000);
const white = Color(0xffffffff);

final identityTransform = new Float64List(16)
  ..[0] = 1.0
  ..[5] = 1.0
  ..[10] = 1.0
  ..[15] = 1.0;

TextPainter createTextPainter() {
  return new TextPainter(
    text: new TextSpan(
      text: '8',
      style: new TextStyle(
        color: black,
        fontSize: 200.0,
        fontFamily: "Roboto",
      ),
    ),
    textDirection: TextDirection.ltr,
  )..layout();
}

void drawBackground(Canvas canvas, Rect screen) {
  canvas.drawRect(
      screen,
      new Paint()
        ..color = white
        ..style = PaintingStyle.fill);
}

Picture createPicture() {
  final recorder = new PictureRecorder();
  final screen = Offset.zero & window.physicalSize;
  final canvas = new Canvas(recorder, screen);
  final offset = screen.center;
  final painter = createTextPainter();
  final bounds = offset & Size(painter.minIntrinsicWidth, painter.height);
  drawBackground(canvas, screen);
  painter.paint(canvas, offset);
  canvas.drawRect(
      bounds,
      new Paint()
        ..color = black
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3.0);
  return recorder.endRecording();
}

Scene createScene() {
  final builder = new SceneBuilder()
    ..pushTransform(identityTransform)
    ..addPicture(Offset.zero, createPicture())
    ..pop();
  return builder.build();
}

void beginFrame(Duration timeStamp) {
  window.render(createScene());
}

void main() {
  window.onBeginFrame = beginFrame;
  window.scheduleFrame();
}
Uwe Keim
  • 39,551
  • 56
  • 175
  • 291
Brian Engel
  • 121
  • 1
  • 4
  • Which font are you using? Are you sure that the font itself doesn't have space around the glyphs? – rmtmckenzie Aug 16 '18 at 17:56
  • I can't imagine the default fault being bugged out like that (is that even possible with ttf?), but in any case I added `fontFamily: "Roboto"` and the result is the same. – Brian Engel Aug 16 '18 at 18:24
  • To be clear, getTextBounds for Android gives the rectangle such that each side barley touches black pixels of the "8" in the linked-to image, and I tested this with Roboto. – Brian Engel Aug 16 '18 at 18:31
  • ok fair. I did just try it out with a serif font and some characters did touch the horizontal edges of the box - but that doesn't help you much. It seems as though flutter does something internally to space out the characters... – rmtmckenzie Aug 16 '18 at 18:35
  • You may be SOL for using things supported by flutter directly. I can give you an alternative though, but it will be a fair amount of work... it depends on how much time you want to invest and how much text you actually need to do (if it's `artistic` as you've said, I'm assuming not all that much). – rmtmckenzie Aug 16 '18 at 18:37
  • (I can't believe I made those typos: "default fault" should be "default font"; "barley" should be "barely".) The main problem is to find the input font size for some given text such that the text barely touches the bounds of a given rectangle. `getTextBounds` allows me to solve that problem using a bisection algorithm (calculations based upon pixel density constants are inaccurate). If either that problem or the subproblem posed in this post can be solved with flutter, I'd certainly be interested, even if some work is needed. – Brian Engel Aug 16 '18 at 19:02
  • Oh, do you propose writing to an image then scanning it? That's how the problem is solved for web/javascript, which also has inadequate text-measuring tools. – Brian Engel Aug 16 '18 at 19:05
  • No worries, I understood what you meant =D. But Hmmm. Well TBH what I was going to propose is to convert the TTF to SVG (lots of converters online) and extract the paths for the glyphs you need. However, since flutter doesn't support SVG very well there's some massaging you'd need to do the SVG to get it to work - you could fairly easily convert the glyphs to simple path elements (using regex etc) which I believe the SVG plugin does support. Or you could use the path data to figure out what to write using a flutter Path (i.e. moveTo, arcTo etc), which is where the work would come in. – rmtmckenzie Aug 16 '18 at 19:11
  • While that would give you absolute control over each character you draw, I don't know that it would do what you want. For the record, I've essentially done what I've described there but rather than for text, I did it for all of the icons I need in my app. I also have some code for converting a path to a series of path commands, although it's pretty crappy code haha. – rmtmckenzie Aug 16 '18 at 19:13
  • Since I only need to find the bounds, drawing the text to an image then looking at `Image.toByteData()` (which was added quite recently) seems like the easiest way to go. There's only a limited number of text-measuring calculations that need to be done. Thanks for your time. – Brian Engel Aug 16 '18 at 20:17
  • @BrianEngel If you found a solution, feel free to answer your own question so others can profit from it too. – Marcel Jun 17 '19 at 15:27

3 Answers3

4

To ignore that padding you have to make really low level calls as the Text and the space practically get painted together. I tried searching if there is any way other way out in on the Internet but couldn't find anything which could actually solve your issue.

I tried hunting through the source code of Flutter SDK (Widget_Library), but all I could see is that the final resultant widget is a RichText whose source code disappears into thin air. (I couldn't find that widget's definition as I just searched it on GitHub...In Android Studio you might get some reference to it by hovering over it and pressing Ctrl+LeftClick... But I am pretty sure that it will take you to another unknown world as fonts are painted based on their font family(which would be .ttf or .otf file...Either ways they'll be painted along with the padding your referring to).

All you can do for now is create our own character set widget which accepts a raw string and reads through each character and paints a number/character already defined by you using some custom paint function,

or if your ready to let go that padding to save some time(which might or might not be crucial for you right now), you either use some predefined function to get that along with the space or use the below solution to get the dimensions of any widget you want.

I need to know the exact bounds a piece of text...

The easiest way to know the width of the any widget is by using a GlobalKey

Declare and initialize a variable to store a GlobalKey,

GlobalKey textKey = GlobalKey();

Pass the GlobalKey to the key parameter of your Text widget,

Text(
  "hello",
   key: textKey,
 )

Now I'll wrap the above widget with a GestureDetector to print the width of the above widget.

GestureDetector(
  onTap: (){
    print(textKey.currentContext.size.width); //This is your width
    print(textKey.currentContext.size.height); //This is your height
      },
   child: Text(
     "hello",
     key: textKey,
       ),
     )

As I mentioned earlier you can use it on any widget, so can you use it on TextSpan widget:

GestureDetector(
  onTap: (){
    print(textKey.currentContext.size.width); //This is your width
      },
TextSpan(
      key: textKey,
      text: '8',
      style: new TextStyle(
        color: black,
        fontSize: 200.0,
        fontFamily: "Roboto",
      ),
    ),
  )

Now with the help of GlobalKey() you can get all the information required about that piece of Text and paint your boundary/rectangle accordingly.

Note: GlobalKey() gives your widget a unique identity, so don't reuse it on multiple widgets.

==================

Edit:

If can somehow get the color of each and every pixel in your app/text, then there is a possibility of getting what your trying to achieve.

Note: You'll have to find the offset/padding of each side separately.

x1,y1 are the co-ordinates for the starting point of your container and x2,y2 are the ending points.

minL,minR,minT,minB are the offset(s) you were looking for.

Basic Logic: Scan each line vertically/horizontally based on the side taken under consideration, until you get a pixel whose color equals the text color and do this for all the imaginary lines until you get the minimum offset(distance of the text from the boundary) for all sides.

Imagine your drawing a line from the boundary of your Container until you meet one of the bounds of the text(/meet the text color) and you do that for all other set of (horizontal[Left/Right] or vertical[Top/Bottom])lines and you just keep the shortest distance in your mind.

// Left Side
for(i=y1+1;i<y2;i++)
 for(j=x1+1;j<x2;j++)
   if(colorof(j,i)==textcolor && minL>j) {minL = j; break;}

// Right Side
for(i=y1+1;i<y2;i++)
 for(j=x2-1;j>x1;j--)
   if(colorof(j,i)==textcolor && minR>j) {minR = j; break;}

// Top Side
for(i=x1+1;i<x2;i++)
 for(j=y1;j<y2;j++)
   if(colorof(i,j)==textcolor && minT>j) {minT = j; break;}

// Bottom Side
for(i=x1+1;i<x1;i++)
 for(j=y2;j>y1;j--)
   if(colorof(i,j)==textcolor && minB>j) {minB = j; break;}

x1 = x1 + minL;
y1 = y1 + minT;
x2 = x2 - minR;
y2 = y2 - minB;

While implementing the above logic ensure that there is no other overlapping/underlying widget(Scaffold/Stack Combo) whose color somehow matches the text color.

Mohit Shetty
  • 1,551
  • 8
  • 26
  • 1
    The source code of RichText doesn't disappear into thin air (see [this article](https://www.raywenderlich.com/4562681-flutter-text-rendering)). However, I don't know of anything that measures the actual text rather than text+padding. Did you do any measurements on the `8`? I suspect that it includes the padding. – Suragch Sep 07 '19 at 12:31
  • As indirectly mentioned in the answer, in sooth there is no such padding explicitly given by Flutter, it all gets painted together depending on the font family of the text...Imagine if you chose a stylish font which had it's 8 twisted diagonally as far as possible within it's bounds and you were asked to find it's min. vertical offset..How would you differentiate between the two? – Mohit Shetty Sep 07 '19 at 18:16
  • I roughly read through the article you commented about...I couldn't find any method which would somehow know the offset + There was no direct mention of font-family in it...Read the **Way Down: Flutter’s Text Engine** portion of that page...That might help you in some way, but you'll have to surely spend a good amount of time understanding everything and re-designing the way Flutter works to get what your looking for - the exact bounds. – Mohit Shetty Sep 07 '19 at 18:23
  • There is one thing you can do...I have appended the solution in my answer. – Mohit Shetty Sep 07 '19 at 19:17
  • Your edit is an interesting solution (since there appears to be no other solution at present). The bounty is yours. – Suragch Sep 08 '19 at 05:14
  • Thanks..I hope you understood the what I meant by the above solution. – Mohit Shetty Sep 08 '19 at 07:25
0

My way to find the smallest rectangle that completely encloses a single-line text:

red text tightly enclosed by rectangle

import 'package:flutter/material.dart';
import 'dart:ui' as ui;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: MyWidget(),
      ),
    );
  }
}

class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  Future<ui.Rect>? _boundsRequest;

  @override
  Widget build(BuildContext context) {
    const textSpan = TextSpan(
      text: 'pixel',
      style: TextStyle(
        color: Colors.red,
        fontSize: 300,
        fontStyle: FontStyle.italic,
      ),
    );
    _boundsRequest ??= getTextBounds(text: textSpan, context: context);
    return Padding(
      padding: const EdgeInsets.only(left: 16),
      child: FutureBuilder(
        future: _boundsRequest,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.done) {
            final bounds = snapshot.data;
            if (bounds != null) {
              return Stack(
                clipBehavior: Clip.none,
                children: [
                  Positioned(
                    top: bounds.top,
                    left: bounds.left,
                    child: Container(
                      width: bounds.width,
                      height: bounds.height,
                      decoration: BoxDecoration(
                        border: Border.all(
                            color: Colors.black,
                            strokeAlign: StrokeAlign.outside),
                      ),
                    ),
                  ),
                  const Text.rich(textSpan),
                ],
              );
            }
          }
          return Container();
        },
      ),
    );
  }
}

Future<Rect> getTextBounds({
  required TextSpan text,
  required BuildContext context,
  ui.TextDirection textDirection = ui.TextDirection.ltr,
}) async {
  final recorder = ui.PictureRecorder();
  final canvas = ui.Canvas(recorder);
  final textScaleFactor = MediaQuery.of(context).textScaleFactor;
  final painter = TextPainter(
    text: text,
    textAlign: ui.TextAlign.center,
    textScaleFactor: textScaleFactor,
    textDirection: textDirection,
  );
  painter.layout();
  final extraWidth = 2 * painter.height.toInt();
  final width = painter.width.toInt() + extraWidth;
  final height = painter.height.toInt();
  final shift = extraWidth / 2; // Create extra space to the left
  painter.paint(canvas, ui.Offset(shift, 0));
  final ui.Image image = await recorder.endRecording().toImage(width, height);
  final bounds = await _getImageBounds(image);
  return ui.Rect.fromLTWH(
    bounds.left - shift,
    bounds.top,
    bounds.width,
    bounds.height,
  );
}

Future<ui.Rect> _getImageBounds(ui.Image image) async {
  final data = await image.toByteData();
  if (data != null) {
    final list = data.buffer.asUint32List();
    return _getBufferBounds(list, image.width, image.height);
  }
  return Rect.zero;
}

// https://pub.dev/documentation/image/latest/image/findTrim.html
ui.Rect _getBufferBounds(
  List<int> list,
  int width,
  int height,
) {
  int getPixel(int x, int y) => list[y * width + x];

  var left = width;
  var right = 0;
  int? top;
  var bottom = 0;

  for (int y = 0; y < height; ++y) {
    var first = true;
    for (int x = 0; x < width; ++x) {
      // 8 bits alpha chanel threshold (0-254):
      const threshold = 64;
      if (getPixel(x, y) >>> 24 > threshold) {
        if (x < left) {
          left = x;
        }

        if (x > right) {
          right = x;
        }

        top ??= y;

        bottom = y;

        if (first) {
          first = false;
          x = right;
        }
      }
    }
  }

  if (top == null) {
    return ui.Rect.fromLTWH(
      0,
      0,
      width.toDouble(),
      height.toDouble(),
    );
  }

  return ui.Rect.fromLTRB(
    left.toDouble(),
    top.toDouble(),
    right.toDouble() + 1,
    bottom.toDouble() + 1,
  );
}
  • I use TextPainter to draw the text into a ui.Image. Then I search for the transparent pixels to calculate the bounds.
  • The bounding rectangle can also have negative values.
  • (Caution) Documentation on TextPainter.width: The horizontal space required to paint this text. Not quite right: With many fonts and italics, the space is exceeded both to the left and to the right. Therefore, I reserve some extra space for the width of the temporary image.
  • (Caution) The rectangle is only correct if the text itself can determine how much space it takes up. But if there is too little space, the text may be scaled down or wrapped into multiple lines.
andreas1724
  • 2,973
  • 1
  • 12
  • 10
-1

You can use the TextPainter class to determine the width of some text. https://api.flutter.dev/flutter/painting/TextPainter-class.html Flutter uses that during layout to determine the size of a text widget.

Not sure if the result from TextPainter will include a small amount of padding or not, though if it does, the already proposed solution will likely do so too.

Tom Robinson
  • 531
  • 3
  • 7