pretext

pub.dev CI License: MIT

A Dart port of chenglou/pretext — pure-arithmetic multiline text layout with variable per-line widths.

The killer feature is layoutNextLine(): wrap text with a different maxWidth per line, enabling text to flow around images, shapes, or any obstacle. Flutter's TextPainter cannot do this — it takes a single maxWidth for the whole paragraph.

Why?

Flutter's TextPainter is great for fixed-width paragraphs. But some layouts need per-line control:

  • Text flowing around a floated image (magazine-style)
  • Masonry / variable-column layouts
  • Custom text editors with inline widgets
  • "Shrinkwrap" containers (find the minimum width that still wraps to N lines)

Getting started

dependencies:
  pretext: ^0.1.0

Usage

Pure Dart (no Flutter dependency)

Provide your own font-measurement function:

import 'package:pretext/pretext.dart';

// Your measurement callback — called once per unique segment, cached by you.
double myMeasure(String seg) => seg.length * 8.5; // monospace example

// Use-case 1: just height + line count
final prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', myMeasure);
final result = layout(prepared, maxWidth: 320, lineHeight: 26);
print('${result.lineCount} lines, ${result.height}px tall');

// Use-case 2: per-line strings
final preparedWS = prepareWithSegments(text, myMeasure);
final result2 = layoutWithLines(preparedWS, 320, 26);
for (final line in result2.lines) {
  print('${line.text}  (${line.width}px)');
}

// Use-case 3: variable-width lines (flows around an image)
var cursor = LayoutCursor.start;
while (true) {
  final isNarrow = cursor.segmentIndex < 5; // first few lines beside image
  final line = layoutNextLine(preparedWS, cursor, isNarrow ? 150.0 : 320.0);
  if (line == null) break;
  canvas.drawText(line.text, x: 0, y: y);
  cursor = line.end;
  y += 26;
}

// Use-case 4: widths without strings (binary-search optimal container width)
double maxW = 0;
walkLineRanges(preparedWS, 320, (line) {
  if (line.width > maxW) maxW = line.width;
});
// maxW = tightest container that still fits all text

Flutter (TextPainter-based measurement)

import 'package:pretext/flutter/pretext_flutter.dart';

const style = TextStyle(fontSize: 16, fontFamily: 'Inter');
final prepared = prepareWithSegmentsForStyle(text, style);

// Simple layout
final result = layoutWithLines(prepared, maxWidth: 320, lineHeight: 24);

// Variable-width (CustomPainter example)
var cursor = LayoutCursor.start;
double y = 0;
while (true) {
  final lineMaxW = y < imageBottom ? canvasW - imageW - 8 : canvasW;
  final line = layoutNextLine(prepared, cursor, lineMaxW);
  if (line == null) break;
  tp.text = TextSpan(text: line.text, style: style);
  tp.layout(maxWidth: lineMaxW);
  tp.paint(canvas, Offset(0, y));
  cursor = line.end;
  y += 24;
}

Reusing a measurer

For hot paths (virtualized lists, frequent repaints), create one TextPainterMeasure and reuse it:

final measurer = TextPainterMeasure(style);
// measurer.measure() is called once per unique segment then cached
final p1 = prepareWithSegments(text1, measurer.measure);
final p2 = prepareWithSegments(text2, measurer.measure);

API reference

Core (pure Dart — package:pretext/pretext.dart)

typedef MeasureFn = double Function(String segment);
enum WhiteSpace { normal, preWrap }

// Use-case 1
PreparedText prepare(String text, MeasureFn measure, {WhiteSpace whiteSpace});
LayoutResult layout(PreparedText prepared, double maxWidth, double lineHeight);
// LayoutResult: { double height, int lineCount }

// Use-case 2
PreparedTextWithSegments prepareWithSegments(String text, MeasureFn measure, {WhiteSpace whiteSpace});
LayoutResultWithLines layoutWithLines(PreparedTextWithSegments, double maxWidth, double lineHeight);
// LayoutResultWithLines: { double height, int lineCount, List<LayoutLine> lines }

LayoutLine? layoutNextLine(PreparedTextWithSegments, LayoutCursor start, double maxWidth);
// → null when exhausted

int walkLineRanges(PreparedTextWithSegments, double maxWidth, void Function(LayoutLineRange) onLine);
// → line count

Cursor types

class LayoutCursor {
  final int segmentIndex;
  final int graphemeIndex; // reserved; always 0 in v0.1
  static const start = LayoutCursor(segmentIndex: 0, graphemeIndex: 0);
}

class LayoutLine   { String text; double width; LayoutCursor start, end; }
class LayoutLineRange { double width; LayoutCursor start, end; } // no text string

Flutter (package:pretext/flutter/pretext_flutter.dart)

PreparedText prepareForStyle(String text, TextStyle style, {WhiteSpace, double textScaleFactor});
PreparedTextWithSegments prepareWithSegmentsForStyle(String text, TextStyle style, {WhiteSpace, double textScaleFactor});

class TextPainterMeasure {
  TextPainterMeasure(TextStyle style, {double textScaleFactor = 1.0});
  double measure(String segment); // cached
  void clearCache();
}

Publishing

The first publish requires a browser (OAuth). Run this on your local machine (not the Pi):

git clone https://github.com/craigm26/pretext_dart /tmp/pretext_dart
cd /tmp/pretext_dart
dart pub publish

After that, future releases are fully automated — just push a version tag:

git tag v0.1.1 && git push origin v0.1.1

GitHub Actions publishes to pub.dev automatically via OIDC trusted publishing (no tokens needed).

Credits

This package is a Dart port of pretext by @chenglou. All the core ideas — measurement-once, cursor-based layout, variable-width lines — are from the original JS library. Go star it.

License

MIT

Libraries

pretext
Dart port of pretext — pure-arithmetic multiline text layout with variable per-line widths.