WIP 글 뷰어: 긴 줄을 재는 조각이 이모지 (두 단위 글자) 가운데서 끊겨 "not well-formed UTF-16" 이 나던 것 (검토 대기)

28 지적 (10/19, 4716537 의 회귀): 이모지가 든 긴 줄에서 cut 콜백의 재는 조각 끝 (from + size) 이 서로게이트 쌍 가운데 (0xD800 다음) 에 떨어지면
반쪽 글자로 TextSpan 을 만들어 painting 오류 "string is not well-formed UTF-16" 이 화면마다 30번 나고, 그 재기를 버리고 더 큰 조각을 다시 쟀다
(화면에 빠진 글은 없었음).
→ 조각 끝이 두 단위 글자의 뒷쪽 (0xDC00~0xDFFF) 이면 한 칸 앞에서 자른다 (viewerRows 뒤쪽과 같은 처리).
같이: 4716537 에서 l10n 검사에 걸린 폭 재기 글자 '가' 에 // l10n-skip (화면 글이 아님).

확인: integration_test/windows_textview_shot_test.dart 에 이모지 줄 ('abc😀' × 3000, 412 · 960) 과 "그리는 중 오류 없음" 을 더함 -
고치기 전 코드로는 Windows 에서 UTF-16 오류 10건으로 실패, 고친 뒤 8 통과 · 오류 0 (release\test\shots_textview_emoji).
xplore_tools_test (새 1: 이모지 긴 줄 · 조각이 반쪽 이모지로 끝나지 않음 - flutter_tester 엔진은 반쪽 글자를 받아들여 회귀는 Windows 캡처로 잡음) 8 통과,
analyze 문제 없음, l10n 빠짐 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next2
RyuJongJin 2026-10-19 22:30:46 +09:00
parent 22ae03953d
commit 7cd8eb8b15
3 changed files with 29 additions and 1 deletions

View File

@ -21,6 +21,8 @@ void main() {
('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()),
('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()),
('korean', '가나다라마바사아자차카타파하' * 200),
// 28 지적 (10/19): 이모지 (두 단위 글자) 가 든 긴 줄 - 재는 조각이 글자 가운데서 끊기면 UTF-16 오류
('emoji', List.generate(3000, (i) => 'abc😀').join()),
]) {
testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
@ -44,6 +46,7 @@ void main() {
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
expect(t.takeException(), isNull, reason: '그리는 중 오류 없음 ($name $w)');
await t.pumpWidget(const SizedBox());
});
}

View File

@ -122,6 +122,7 @@ class _TextViewerPageState extends State<TextViewerPage> {
/// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게)
double _wideRatio(BuildContext context) {
// l10n-skip: 폭을 재는 넓은 글자 (화면 글이 아님)
final m = _measure(context, 'M'), w = _measure(context, '가');
return m == null || w == null ? 2 : w / m;
}
@ -158,7 +159,10 @@ class _TextViewerPageState extends State<TextViewerPage> {
_rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) {
var size = perRow * 8;
while (true) {
final end = (from + size).clamp(0, line.length);
var end = (from + size).clamp(0, line.length);
// 28 지적 (10/19): 재는 조각이 이모지 같은 두 단위 글자의 가운데에서 끝나면 반쪽 글자라 TextSpan 이
// "not well-formed UTF-16" 으로 실패했다 (화면은 정상이나 오류가 쏟아지고 더 큰 조각을 다시 잼) - 글자 앞에서 자른다
if (end < line.length && end > from + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--;
tp.text = TextSpan(text: line.substring(from, end), style: style);
tp.layout(maxWidth: room);
final m = tp.computeLineMetrics();

View File

@ -143,6 +143,27 @@ void main() {
expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)');
});
testWidgets('h 글 뷰어 (28 지적 10/19): 이모지가 든 긴 줄 - 재는 조각이 두 단위 글자 가운데서 끊기지 않음 (UTF-16 오류 없음)', (t) async {
final line = List.generate(3000, (i) => 'abc😀').join();
final f = File(p.join(tmp.path, 'emoji.txt'))..writeAsStringSync('$line\nend');
for (final w in [412.0, 960.0]) {
t.view.physicalSize = Size(w, 900);
t.view.devicePixelRatio = 1;
await t.pumpWidget(MaterialApp(home: TextViewerPage(key: ValueKey(w), path: f.path)));
for (var i = 0; i < 40 && find.textContaining('abc😀abc').evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
expect(t.takeException(), isNull, reason: '폭 $w');
final texts = t.widgetList<Text>(find.textContaining('abc😀')).map((x) => x.data!).toList();
expect(texts, isNotEmpty);
for (final s in texts) {
expect((s.codeUnitAt(s.length - 1) & 0xFC00) == 0xD800, isFalse, reason: '조각이 반쪽 이모지로 끝나지 않음');
}
}
t.view.reset();
});
testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async {
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
final opened = <String>[];