WIP 글 뷰어: 긴 줄을 재는 조각이 이모지 (두 단위 글자) 가운데서 끊겨 "not well-formed UTF-16" 이 나던 것 (검토 대기)
28 지적 (10/19,next24716537의 회귀): 이모지가 든 긴 줄에서 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>
parent
22ae03953d
commit
7cd8eb8b15
|
|
@ -21,6 +21,8 @@ void main() {
|
||||||
('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()),
|
('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()),
|
||||||
('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()),
|
('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()),
|
||||||
('korean', '가나다라마바사아자차카타파하' * 200),
|
('korean', '가나다라마바사아자차카타파하' * 200),
|
||||||
|
// 28 지적 (10/19): 이모지 (두 단위 글자) 가 든 긴 줄 - 재는 조각이 글자 가운데서 끊기면 UTF-16 오류
|
||||||
|
('emoji', List.generate(3000, (i) => 'abc😀').join()),
|
||||||
]) {
|
]) {
|
||||||
testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async {
|
testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async {
|
||||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
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));
|
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||||
Directory(out).createSync(recursive: true);
|
Directory(out).createSync(recursive: true);
|
||||||
File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
expect(t.takeException(), isNull, reason: '그리는 중 오류 없음 ($name $w)');
|
||||||
await t.pumpWidget(const SizedBox());
|
await t.pumpWidget(const SizedBox());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ class _TextViewerPageState extends State<TextViewerPage> {
|
||||||
|
|
||||||
/// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게)
|
/// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게)
|
||||||
double _wideRatio(BuildContext context) {
|
double _wideRatio(BuildContext context) {
|
||||||
|
// l10n-skip: 폭을 재는 넓은 글자 (화면 글이 아님)
|
||||||
final m = _measure(context, 'M'), w = _measure(context, '가');
|
final m = _measure(context, 'M'), w = _measure(context, '가');
|
||||||
return m == null || w == null ? 2 : w / m;
|
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) {
|
_rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) {
|
||||||
var size = perRow * 8;
|
var size = perRow * 8;
|
||||||
while (true) {
|
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.text = TextSpan(text: line.substring(from, end), style: style);
|
||||||
tp.layout(maxWidth: room);
|
tp.layout(maxWidth: room);
|
||||||
final m = tp.computeLineMetrics();
|
final m = tp.computeLineMetrics();
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,27 @@ void main() {
|
||||||
expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)');
|
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 {
|
testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async {
|
||||||
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
|
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
|
||||||
final opened = <String>[];
|
final opened = <String>[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue