Created
January 7, 2025 15:35
-
-
Save willsmanley/6aa0d9c209565ffddb8543a932816dd5 to your computer and use it in GitHub Desktop.
text_video.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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