WIP 글 뷰어: 긴 줄을 실제 글꼴로 줄을 바꿔 본 자리 (네 줄 끝) 에서 나눔 - 넓은 글자 · 고정폭이 아닌 글꼴에서도 줄 끝이 비지 않게 (검토 대기)

관리자 지시 (10/19, 27baf91 의 ③ 남은 것): 한글 · 한자처럼 넓은 글자가 섞인 줄도 줄 끝이 비지 않게.
Windows 캡처로 보니 'monospace' 가 실제로 고정폭이 아니라, 글자 수로 세면 (가장 넓은 'M' 기준) 영문 줄도 넷째 줄 끝이 비었다.
- 화면에서는 TextPainter 에 Text 와 같은 글꼴 · 글자 크기 · 기기 배율 · 폭으로 조각을 그려 보고, 다섯째 줄이 시작하는 자리 (= 네 줄의 끝) 에서 자른다.
  (조각 창은 화면 한 줄 글자 수 × 8 부터, 다섯 줄이 안 되면 두 배로). 한 번 나눈 결과는 글 · 폭 · 글자 크기가 같으면 다시 쓰지 않는다.
- viewerRows 는 cut (줄 끝 자리) 이 없으면 글자 폭 단위로: 넓은 글자 (isWideChar - 한글 자모 · 음절, 한자 · 가나 · 한중일 기호, 전각) 와 이모지는
  잰 비율 (기본 2) 칸으로 센다. 두 단위 글자는 반으로 자르지 않는다.
- 새 캡처 시험 integration_test/windows_textview_shot_test.dart (영문 · "한x" 섞임 · 한글만, 412 / 960).

확인: Windows 캡처 (release\test\shots_textview2 - 고치기 전 shots_textview 에서는 영문 · 섞인 줄의 넷째 줄 끝이 비었음) 6 통과,
xplore_tools_test (새 2: 넓은 글자 칸 세기 · 한글 섞인 긴 줄의 조각이 화면 네 줄) 7 통과, explorer X-plore g · h 통과, 임시 폴더 흔적 0, analyze 문제 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next2
RyuJongJin 2026-10-19 22:05:43 +09:00
parent 27baf91876
commit 47165371eb
3 changed files with 169 additions and 18 deletions

View File

@ -0,0 +1,51 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/ui/text_viewer.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 관리자 지시 (10/19): 글 뷰어가 줄바꿈 없는 긴 줄 (영문 · 한글 섞임) 을 나눠 그릴 때 실제 Windows 글꼴에서 줄 끝이 비지 않는지 PNG 로.
/// 크기 412 · 960. 실행: flutter test integration_test\windows_textview_shot_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_textview');
for (final (w, h) in [(412, 900), (960, 600)]) {
for (final (name, line) in [
('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()),
('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()),
('korean', '가나다라마바사아자차카타파하' * 200),
]) {
testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final dir = testTempDir('jj_textview_');
final f = File(p.join(dir.path, '$name.txt'))..writeAsStringSync('$line\nend');
final key = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: key,
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: TextViewerPage(path: f.path)),
));
for (var i = 0; i < 40 && find.text('end').evaluate().isEmpty && find.textContaining(line.substring(0, 6)).evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump();
}
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
await t.pump();
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
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());
await t.pumpWidget(const SizedBox());
});
}
}
}

View File

@ -118,28 +118,61 @@ class _TextViewerPageState extends State<TextViewerPage> {
}
/// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (28 지적: 4줄마다 줄 끝이 비었다)
double _charWidth(BuildContext context) {
double _charWidth(BuildContext context) => _measure(context, 'M') ?? _font * 0.62;
/// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게)
double _wideRatio(BuildContext context) {
final m = _measure(context, 'M'), w = _measure(context, '가');
return m == null || w == null ? 2 : w / m;
}
double? _measure(BuildContext context, String ch) {
final tp = TextPainter(
text: TextSpan(text: 'M' * 40, style: DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'))),
text: TextSpan(text: ch * 40, style: DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'))),
textDirection: TextDirection.ltr,
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout();
final w = tp.width / 40;
tp.dispose();
return w > 0 ? w : _font * 0.62;
return w > 0 ? w : null;
}
/// 그릴 줄 (줄 번호 · 글) - 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다
/// 그릴 줄 (줄 번호 · 글) - 글 · 폭 · 글자 크기가 같으면 다시 만들지 않는다
List<(int, String)> _rows = const [];
String? _rowsText;
int _rowsPer = 0;
String _rowsKey = '';
List<(int, String)> _rowsFor(int perRow) {
if (!identical(_rowsText, _text) || _rowsPer != perRow) {
/// [room] 폭에 [style] 로 그릴 때의 줄. 아주 긴 줄은 화면에서 실제로 네 줄이 끝나는 자리 (TextPainter 로 그 글꼴 그대로 줄을 바꿔 봄) 에서 자른다 -
/// 관리자 지시 (10/19): Windows 의 'monospace' 는 고정폭이 아니라 글자 수로 세면 넷째 줄 끝이 비었다 (영문 · 한글 섞인 줄 모두)
List<(int, String)> _rowsFor(BuildContext context, double room) {
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'));
final scaler = MediaQuery.textScalerOf(context);
final perRow = (room / _charWidth(context)).floor().clamp(20, 4000);
final key = '$room|$_font|${scaler.scale(100)}|$perRow';
if (identical(_rowsText, _text) && _rowsKey == key) return _rows;
_rowsText = _text;
_rowsPer = perRow;
_rows = viewerRows(_text, perRow);
_rowsKey = key;
final tp = TextPainter(textDirection: Directionality.of(context), textScaler: scaler);
try {
_rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) {
var size = perRow * 8;
while (true) {
final end = (from + size).clamp(0, line.length);
tp.text = TextSpan(text: line.substring(from, end), style: style);
tp.layout(maxWidth: room);
final m = tp.computeLineMetrics();
if (m.length > 4) {
// 다섯째 줄의 시작 = 네 줄의 끝
final at = tp.getPositionForOffset(Offset(0, m[4].baseline - m[4].ascent / 2)).offset;
if (at > 0) return from + at;
}
if (end >= line.length) return line.length;
size *= 2;
}
});
} finally {
tp.dispose();
}
return _rows;
}
@ -219,7 +252,7 @@ class _TextViewerPageState extends State<TextViewerPage> {
// 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 -
// 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다
final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0);
final rows = _rowsFor((room / _charWidth(context)).floor().clamp(20, 4000));
final rows = _rowsFor(context, room);
return Scrollbar(
controller: _scroll,
child: ListView.builder(
@ -263,12 +296,14 @@ class _TextViewerPageState extends State<TextViewerPage> {
}
}
/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 [perRow] 자가 들 때,
/// 그보다 8배 넘게 긴 줄은 4배 (화면 네 줄쯤) 씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈)
List<(int, String)> viewerRows(String text, int perRow) {
/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 고정폭 글자 [perRow] 개가 들 때,
/// 그보다 8배 넘게 긴 줄은 화면 네 줄 분량씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈).
/// 넓은 글자 (한글 · 한자 · 가나 · 전각 · 이모지) 는 고정폭 글자 [wide] 개 폭으로 세어 (관리자 지시 10/19) 넓은 글자가 섞여도 네 줄이 꽉 차게.
/// [cut] 이 있으면 (화면) 조각의 끝을 그것이 정한다 - 글꼴로 실제 줄을 바꿔 본 자리 (글자 수 어림보다 정확)
List<(int, String)> viewerRows(String text, int perRow, {double wide = 2, int Function(String line, int from)? cut}) {
final out = <(int, String)>[];
final lines = text.split('\n');
final piece = perRow * 4;
final budget = perRow * 4.0;
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.length <= perRow * 8) {
@ -277,12 +312,39 @@ List<(int, String)> viewerRows(String text, int perRow) {
}
var at = 0;
while (at < line.length) {
var end = (at + piece).clamp(0, line.length);
final byLayout = cut?.call(line, at);
if (byLayout != null && byLayout > at) {
var end = byLayout.clamp(at + 1, line.length);
// 두 단위 글자 (이모지 등) 를 반으로 자르지 않게
if (end < line.length && end > at + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--;
if (end < line.length && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end++;
out.add((at == 0 ? i + 1 : 0, line.substring(at, end)));
at = end;
continue;
}
var end = at;
var used = 0.0;
while (end < line.length) {
final c = line.codeUnitAt(end);
// 두 단위 글자 (이모지 등) 는 한 덩어리로 - 반으로 자르지 않게
final pair = (c & 0xFC00) == 0xD800 && end + 1 < line.length && (line.codeUnitAt(end + 1) & 0xFC00) == 0xDC00;
final w = pair || isWideChar(c) ? wide : 1.0;
if (used + w > budget + 1e-6 && end > at) break;
used += w;
end += pair ? 2 : 1;
}
out.add((at == 0 ? i + 1 : 0, line.substring(at, end)));
at = end;
}
}
return out;
}
/// 고정폭 글꼴에서 두 칸을 쓰는 글자인지 (한글 자모 · 음절, 한자 · 가나 · 한중일 기호, 전각)
bool isWideChar(int c) =>
(c >= 0x1100 && c <= 0x115F) ||
(c >= 0x2E80 && c <= 0xA4CF) ||
(c >= 0xAC00 && c <= 0xD7A3) ||
(c >= 0xF900 && c <= 0xFAFF) ||
(c >= 0xFE30 && c <= 0xFE4F) ||
(c >= 0xFF00 && c <= 0xFF60) ||
(c >= 0xFFE0 && c <= 0xFFE6);

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
@ -105,6 +106,43 @@ void main() {
expect(bar.controller!.hasClients, isTrue);
});
test('h 글 뷰어 (관리자 지시 10/19): 넓은 글자 (한글 · 한자) 는 두 칸으로 세어 네 줄 분량이 꽉 차게 나눈다', () {
final line = List.generate(600, (i) => i.isEven ? '가' : 'a').join();
final rows = viewerRows(line, 10, wide: 2);
expect(rows.map((r) => r.$2).join(), line);
double units(String s) => s.runes.fold(0.0, (a, c) => a + (isWideChar(c) ? 2 : 1));
for (final r in rows.take(rows.length - 1)) {
expect(units(r.$2), inInclusiveRange(39, 40), reason: '네 줄 (40칸) 에서 한 칸 넘게 비지 않음: ${r.$2}');
}
expect(isWideChar('漢'.codeUnitAt(0)), isTrue);
expect(isWideChar('ア'.codeUnitAt(0)), isTrue);
expect(isWideChar('A'.codeUnitAt(0)), isTrue);
expect(isWideChar('a'.codeUnitAt(0)), isFalse);
// 비율이 2 가 아닌 글꼴 (잰 값) 도 그대로
final r15 = viewerRows('가' * 400, 10, wide: 1.5);
expect(r15.first.$2.length, 26, reason: '40 / 1.5 = 26.6 → 26자');
});
testWidgets('h 글 뷰어: 한글이 섞인 긴 줄도 나눈 조각마다 화면 네 줄 (다섯째 반쪽 줄 없음)', (t) async {
// 한 글자씩 바뀌어 어디서든 줄을 바꿀 수 있는 글 (영문 낱말이 이어지면 낱말 단위로 줄이 바뀌어 보통 글처럼 줄 끝이 조금 빈다)
final line = List.generate(3000, (i) => i.isEven ? '한' : 'x').join();
final f = File(p.join(tmp.path, 'ko.txt'))..writeAsStringSync(line);
t.view.physicalSize = const Size(412, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path)));
for (var i = 0; i < 40 && find.textContaining('한x한x').evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
final pieces = find.textContaining('한x한x');
expect(pieces.evaluate().length, greaterThan(1));
final first = t.renderObject<RenderParagraph>(find.descendant(of: pieces.first, matching: find.byType(RichText)));
final txt = t.widget<Text>(pieces.first).data!;
final lines = {for (final bx in first.getBoxesForSelection(TextSelection(baseOffset: 0, extentOffset: txt.length))) bx.top}.length;
expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)');
});
testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async {
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
final opened = <String>[];