createTextSprites method
Since the Paragraph rasterizing to the Canvas, and the getting of the Image bytes are async functions, there needs to be an async function not just the constructor. Plus we want the caller to decide how many lines of a long paragraph to rasterize, and when. Text lines as TxSprites are returned as a List, and the caller can decide how many to send to Frame, and when.
Implementation
Future<List<TxSprite>> createTextSprites(String text) async {
final List<TxSprite> sprites = [];
final double dpr = ui.window.devicePixelRatio;
// 1. Force a massive line height to space lines far apart.
// This guarantees ascenders/descenders from adjacent lines
// won't bleed into the current line's isolated bounding box.
final paragraphBuilder = ui.ParagraphBuilder(ui.ParagraphStyle(
textAlign: textAlign,
textDirection: textDirection,
fontFamily: fontFamily,
fontSize: _fontSize.toDouble(),
height: 5.0, // Massive multiplier to isolate lines
));
paragraphBuilder.addText(text);
final ui.Paragraph paragraph = paragraphBuilder.build();
paragraph.layout(ui.ParagraphConstraints(width: width.toDouble()));
List<ui.LineMetrics> lineMetrics = paragraph.computeLineMetrics();
if (lineMetrics.isEmpty) {
return sprites;
}
// 2. Choose a fixed baseline for all lines in this block.
// Placing the baseline at 80% of the line height is standard.
// For a 16px line height, this puts the baseline at Y=13,
// leaving 13px for ascenders and 3px for descenders.
final double fixedBaseline = (_lineHeight * 0.8).roundToDouble();
for (var line in lineMetrics) {
final int lineWidth = line.width.ceil();
// check for non-blank lines
if (lineWidth > 0) {
final pictureRecorder = ui.PictureRecorder();
final canvas = ui.Canvas(pictureRecorder);
// Scale the canvas by DPR so the text is rendered at high fidelity
canvas.scale(dpr);
// Clip the canvas exactly to our sprite dimensions
canvas.clipRect(Rect.fromLTWH(0, 0, lineWidth.toDouble(), _lineHeight.toDouble()));
// 3. Align the line's specific baseline exactly to our fixedBaseline
canvas.translate(-line.left, fixedBaseline - line.baseline);
canvas.drawParagraph(paragraph, ui.Offset.zero);
final ui.Picture picture = pictureRecorder.endRecording();
final int scaledWidth = (lineWidth * dpr).round();
final int scaledHeight = (_lineHeight * dpr).round();
final ui.Image image = await picture.toImage(scaledWidth, scaledHeight);
// Force RGBA for predictable byte-access on Android/iOS
final ByteData? byteData = (await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba))!;
if (byteData == null) continue;
final Uint8List pixels = byteData.buffer.asUint8List();
final Uint8List linePixelData = Uint8List(lineWidth * _lineHeight);
// 4. Downsample: Map the high-res buffer back to your 1-bit grid
for (int y = 0; y < _lineHeight; y++) {
for (int x = 0; x < lineWidth; x++) {
// Calculate the "center" pixel of the DPR-scaled block
// This ensures we aren't sampling an anti-aliased edge pixel
int centerX = (x * dpr + dpr / 2).floor();
int centerY = (y * dpr + dpr / 2).floor();
// Index in the RGBA8888 byte array
int pos = (centerY * scaledWidth + centerX) * 4;
// Thresholding logic:
// iOS Impeller might need a slightly lower threshold (e.g., 100)
// if your fonts appear too thin.
int threshold = 110;
linePixelData[y * lineWidth + x] = pixels[pos] >= threshold ? 1 : 0;
}
}
sprites.add(TxSprite(
width: lineWidth,
height: _lineHeight,
numColors: 2,
paletteData: _getPalette().data,
pixelData: linePixelData,
));
} else {
// zero-width line, a blank line in the text block
sprites.add(TxSprite(
width: 1,
height: 1,
numColors: 2,
paletteData: _getPalette().data,
pixelData: Uint8List(1)
));
}
}
return sprites;
}