Skip to content

Instantly share code, notes, and snippets.

@willsmanley
Created January 7, 2025 15:35
Show Gist options
  • Save willsmanley/6aa0d9c209565ffddb8543a932816dd5 to your computer and use it in GitHub Desktop.
Save willsmanley/6aa0d9c209565ffddb8543a932816dd5 to your computer and use it in GitHub Desktop.
text_video.dart
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'dart:typed_data';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final ByteData fontData = await rootBundle.load('assets/font2.ttf');
final myTTF = _parseTTF(fontData);
runApp(MyApp(myTTF));
}
class MyApp extends StatelessWidget {
final MyTTF ttf;
const MyApp(this.ttf, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Home(ttf: ttf),
);
}
}
class Home extends StatefulWidget {
final MyTTF ttf;
const Home({required this.ttf, Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => _HomeState();
}
class _HomeState extends State<Home> {
VideoPlayerController? _controller;
Path? path;
@override
void initState() {
super.initState();
// Original video stuff
_controller = VideoPlayerController.asset('assets/video.mp4')
..initialize().then((_) {
setState(() {});
_controller?.setVolume(0);
_controller?.play();
_controller?.setLooping(true);
});
const textStr = "thank you simon!";
const fontSize = 50.0;
path = buildPathForText(textStr, widget.ttf, fontSize);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: (path == null || _controller == null)
? const CircularProgressIndicator()
: Container(
color: Colors.black,
child: ClipPath(
clipper: PathClipper(path!),
child: Transform.scale(
scale: 1.6,
child: Center(
child: VideoPlayer(_controller!),
),
),
),
),
),
);
}
}
class PathClipper extends CustomClipper<Path> {
final Path path;
PathClipper(this.path);
@override
Path getClip(Size size) => path;
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
}
// Basic TTF container
class MyTTF {
int unitsPerEm = 2048;
int ascender = 0;
int descender = 0;
int numGlyphs = 0;
int numOfLongHorMetrics = 0;
final List<Map<String, int>> glyphMetrics =
[]; // per glyph: {advanceWidth, leftSideBearing}
final Map<int, dynamic> glyphShapes =
{}; // glyphID -> {contours, xMin, yMin, ...}
final Map<int, int> charToGlyph = {}; // codePoint -> glyphID
}
// Minimal parse aggregator
MyTTF _parseTTF(ByteData data) {
final ttf = MyTTF();
// 1) Read table directory
final numTables = data.getUint16(4);
int offset = 12; // table records start at byte 12
final Map<String, Map<String, int>> tables = {};
for (var i = 0; i < numTables; i++) {
final tag = _readTag(data, offset);
final tblOffset = data.getUint32(offset + 8);
final tblLength = data.getUint32(offset + 12);
offset += 16;
tables[tag] = {'offset': tblOffset, 'length': tblLength};
}
// HEAD
if (tables.containsKey('head')) {
final headOffset = tables['head']!['offset']!;
ttf.unitsPerEm = data.getUint16(headOffset + 18);
}
// HHEA
if (tables.containsKey('hhea')) {
final hheaOffset = tables['hhea']!['offset']!;
ttf.ascender = data.getInt16(hheaOffset + 4);
ttf.descender = data.getInt16(hheaOffset + 6);
ttf.numOfLongHorMetrics = data.getUint16(hheaOffset + 34);
}
// MAXP
if (tables.containsKey('maxp')) {
final maxpOffset = tables['maxp']!['offset']!;
ttf.numGlyphs = data.getUint16(maxpOffset + 4);
}
// CMAP
if (tables.containsKey('cmap')) {
final cmapOffset = tables['cmap']!['offset']!;
_parseCmap(data, cmapOffset, ttf);
}
// HMTX
if (tables.containsKey('hmtx')) {
final hmtxOffset = tables['hmtx']!['offset']!;
_parseHmtx(data, hmtxOffset, ttf);
}
// LOCA + GLYF
if (tables.containsKey('loca') && tables.containsKey('glyf')) {
final locaOffset = tables['loca']!['offset']!;
final glyfOffset = tables['glyf']!['offset']!;
_parseLocaGlyf(data, locaOffset, glyfOffset, ttf);
}
return ttf;
}
String _readTag(ByteData data, int offset) {
final codes = [
data.getUint8(offset),
data.getUint8(offset + 1),
data.getUint8(offset + 2),
data.getUint8(offset + 3)
];
return String.fromCharCodes(codes);
}
// Minimal CMAP parse (format 4 only, ignoring format 12, etc.)
void _parseCmap(ByteData data, int cmapOffset, MyTTF ttf) {
int offset = cmapOffset;
final numTables = data.getUint16(offset + 2);
offset += 4;
int chosenSubtable = -1;
for (var i = 0; i < numTables; i++) {
final platformID = data.getUint16(offset);
final encodingID = data.getUint16(offset + 2);
final subOfs = data.getUint32(offset + 4);
offset += 8;
if (platformID == 3 &&
(encodingID == 0 || encodingID == 1 || encodingID == 10)) {
chosenSubtable = cmapOffset + subOfs;
break;
}
}
if (chosenSubtable >= 0) {
_readFormat4(data, chosenSubtable, ttf);
}
}
// Very minimal Format 4 parse
void _readFormat4(ByteData data, int startOffset, MyTTF ttf) {
final segCount = data.getUint16(startOffset + 6) ~/ 2;
final endCodesOffset = startOffset + 14;
var ofs = endCodesOffset;
final endCodes = List<int>.filled(segCount, 0);
for (var i = 0; i < segCount; i++) {
endCodes[i] = data.getUint16(ofs);
ofs += 2;
}
ofs += 2; // reserved pad
final startCodes = List<int>.filled(segCount, 0);
for (var i = 0; i < segCount; i++) {
startCodes[i] = data.getUint16(ofs);
ofs += 2;
}
final idDeltas = List<int>.filled(segCount, 0);
for (var i = 0; i < segCount; i++) {
idDeltas[i] = data.getInt16(ofs);
ofs += 2;
}
final idRangeOffsets = List<int>.filled(segCount, 0);
for (var i = 0; i < segCount; i++) {
idRangeOffsets[i] = data.getUint16(ofs);
ofs += 2;
}
final glyphIndexArrayStart = ofs;
for (var i = 0; i < segCount; i++) {
for (var c = startCodes[i]; c <= endCodes[i]; c++) {
if (idRangeOffsets[i] == 0) {
final gid = (c + idDeltas[i]) & 0xFFFF;
ttf.charToGlyph[c] = gid;
} else {
final pos = glyphIndexArrayStart +
(idRangeOffsets[i] + 2 * (c - startCodes[i]) + 2 * (i - segCount));
if (pos < data.buffer.lengthInBytes - 1) {
var gid = data.getUint16(pos);
if (gid != 0) {
gid = (gid + idDeltas[i]) & 0xFFFF;
}
ttf.charToGlyph[c] = gid;
}
}
}
}
}
// Parse HMTX
void _parseHmtx(ByteData data, int hmtxOffset, MyTTF ttf) {
final nHMetrics = ttf.numOfLongHorMetrics;
for (var g = 0; g < ttf.numGlyphs; g++) {
if (g < nHMetrics) {
final adv = data.getUint16(hmtxOffset + g * 4);
final lsb = data.getInt16(hmtxOffset + g * 4 + 2);
ttf.glyphMetrics.add({'advanceWidth': adv, 'leftSideBearing': lsb});
} else {
final adv = ttf.glyphMetrics[nHMetrics - 1]['advanceWidth'] ?? 0;
final lsbOfs = hmtxOffset + nHMetrics * 4 + (g - nHMetrics) * 2;
final lsb = data.getInt16(lsbOfs);
ttf.glyphMetrics.add({'advanceWidth': adv, 'leftSideBearing': lsb});
}
}
}
// Parse loca+glyf
void _parseLocaGlyf(ByteData data, int locaOffset, int glyfOffset, MyTTF ttf) {
// We’d read indexToLocFormat from head (offset + 50). For brevity, assume = 1
final indexToLocFormat = 1;
final offsets = <int>[];
if (indexToLocFormat == 0) {
for (var i = 0; i <= ttf.numGlyphs; i++) {
final off16 = data.getUint16(locaOffset + i * 2);
offsets.add(off16 * 2);
}
} else {
for (var i = 0; i <= ttf.numGlyphs; i++) {
final off32 = data.getUint32(locaOffset + i * 4);
offsets.add(off32);
}
}
for (var g = 0; g < ttf.numGlyphs; g++) {
final start = offsets[g];
final end = offsets[g + 1];
if (start == end) continue; // empty
_readGlyf(data, glyfOffset + start, g, ttf);
}
}
// Minimal glyf parse for simple glyphs only
void _readGlyf(ByteData data, int offset, int glyphID, MyTTF ttf) {
var ptr = offset;
final nContours = data.getInt16(ptr);
ptr += 2;
final xMin = data.getInt16(ptr);
ptr += 2;
final yMin = data.getInt16(ptr);
ptr += 2;
final xMax = data.getInt16(ptr);
ptr += 2;
final yMax = data.getInt16(ptr);
ptr += 2;
if (nContours < 0) return; // compound => skip
final endPts = <int>[];
for (var i = 0; i < nContours; i++) {
endPts.add(data.getUint16(ptr));
ptr += 2;
}
final instructionLen = data.getUint16(ptr);
ptr += 2 + instructionLen;
final totalPoints = (endPts.isNotEmpty) ? (endPts.last + 1) : 0;
final flags = <int>[];
var i = 0;
while (i < totalPoints) {
final f = data.getUint8(ptr);
ptr++;
flags.add(f);
if ((f & 0x08) != 0) {
final repeat = data.getUint8(ptr);
ptr++;
for (var r = 0; r < repeat; r++) {
flags.add(f);
}
i += repeat;
}
i++;
}
final points =
List.generate(totalPoints, (_) => {'x': 0, 'y': 0, 'onCurve': false});
var x = 0;
for (var p = 0; p < totalPoints; p++) {
final f = flags[p];
if ((f & 0x02) != 0) {
var dx = data.getUint8(ptr);
ptr++;
if ((f & 0x10) == 0) dx = -dx;
x += dx;
} else if ((f & 0x10) == 0) {
final dx = data.getInt16(ptr);
ptr += 2;
x += dx;
}
points[p]['x'] = x;
}
var y = 0;
for (var p = 0; p < totalPoints; p++) {
final f = flags[p];
if ((f & 0x04) != 0) {
var dy = data.getUint8(ptr);
ptr++;
if ((f & 0x20) == 0) dy = -dy;
y += dy;
} else if ((f & 0x20) == 0) {
final dy = data.getInt16(ptr);
ptr += 2;
y += dy;
}
points[p]['y'] = y;
}
for (var p = 0; p < totalPoints; p++) {
points[p]['onCurve'] = (flags[p] & 1) != 0;
}
final contours = <List<Map<String, dynamic>>>[];
var startIdx = 0;
for (var c = 0; c < nContours; c++) {
final endIdx = endPts[c];
final sub = points.sublist(startIdx, endIdx + 1);
contours.add(sub);
startIdx = endIdx + 1;
}
ttf.glyphShapes[glyphID] = {
'xMin': xMin,
'yMin': yMin,
'xMax': xMax,
'yMax': yMax,
'contours': contours,
};
}
// ------------------------------------------------------------------------
// CHANGED: buildPathForText + a small helper to convert each glyph to Path.
// This is called in initState to create a Path for “Hello”
// ------------------------------------------------------------------------
Path buildPathForText(String text, MyTTF ttf, double fontSize) {
final path = Path();
// If your hhea ascender/descender are set, you can shift glyphs so baseline=0
final baselineShift = ttf.ascender.toDouble();
final scale = fontSize / ttf.unitsPerEm;
double penX = 0;
for (var i = 0; i < text.length; i++) {
final code = text.codeUnitAt(i);
final gid = ttf.charToGlyph[code] ?? 0;
if (gid < 0 || gid >= ttf.numGlyphs) continue;
final m = (gid < ttf.glyphMetrics.length)
? ttf.glyphMetrics[gid]
: {'advanceWidth': 0, 'leftSideBearing': 0};
final adv = m['advanceWidth'] ?? 0;
final lsb = m['leftSideBearing'] ?? 0;
// Build glyph path from shapes
final glyphPath = _buildGlyphPath(ttf, gid);
// Transform: scale + translate so baseline is at y=0
final dx = penX + lsb;
final dy = -baselineShift;
final matrix = Float64List.fromList([
scale,
0,
0,
0,
0,
-scale,
0,
0,
0,
0,
1,
0,
dx * scale,
-dy * scale,
0,
1,
]);
final glyphTransformed = glyphPath.transform(matrix);
path.addPath(glyphTransformed, Offset.zero);
penX += adv.toDouble();
}
return path;
}
Path _buildGlyphPath(MyTTF ttf, int gid) {
final p = Path();
final info = ttf.glyphShapes[gid];
if (info == null) return p;
final contours = info['contours'] as List?;
if (contours == null) return p;
for (final c in contours) {
final seg = _contourToPath(c);
p.addPath(seg, Offset.zero);
}
return p;
}
/// Use logic similar to the original text_to_path_maker for building the path
Path _contourToPath(List contourPoints) {
final path = Path();
if (contourPoints.isEmpty) return path;
// 1) Interpolate consecutive off-curve points
final interpolated = <Map<String, dynamic>>[];
for (var i = 0; i < contourPoints.length - 1; i++) {
final current = contourPoints[i];
final next = contourPoints[i + 1];
interpolated.add(current);
if (!current['onCurve'] && !next['onCurve']) {
final mid = {
'x': (current['x'] + next['x']) / 2.0,
'y': (current['y'] + next['y']) / 2.0,
'onCurve': true
};
interpolated.add(mid);
}
}
// Add the last point, and if it's off-curve, insert midpoint with first
final lastPoint = contourPoints.last;
interpolated.add(lastPoint);
if (!lastPoint['onCurve']) {
final firstPoint = contourPoints.first;
final mid = {
'x': (lastPoint['x'] + firstPoint['x']) / 2.0,
'y': (lastPoint['y'] + firstPoint['y']) / 2.0,
'onCurve': true
};
interpolated.add(mid);
}
// 2) Build the path using MoveTo, LineTo, and Quadratic Bezier (Q)
bool firstCommand = true;
var pos = 0;
for (var i = 0; i < interpolated.length - 1; i++) {
final current = interpolated[i];
final next = interpolated[i + 1];
if (firstCommand) {
path.moveTo(current['x'].toDouble(), current['y'].toDouble());
firstCommand = false;
} else {
if (!current['onCurve']) {
// Quadratic from current => next
path.quadraticBezierTo(
current['x'].toDouble(),
current['y'].toDouble(),
next['x'].toDouble(),
next['y'].toDouble(),
);
i++; // skip the next point since we've used it as the end of Q
} else {
// Line
path.lineTo(current['x'].toDouble(), current['y'].toDouble());
}
}
pos = i;
}
// If there's one leftover point after the loop
if ((pos + 1) < interpolated.length) {
final leftover = interpolated[pos + 1];
path.lineTo(leftover['x'].toDouble(), leftover['y'].toDouble());
}
path.close();
return path;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment