From 47165371eb79282946a75d0bb1421ffef810510b Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:05:43 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EA=B8=80=20=EB=B7=B0=EC=96=B4:=20?= =?UTF-8?q?=EA=B8=B4=20=EC=A4=84=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EA=B8=80?= =?UTF-8?q?=EA=BC=B4=EB=A1=9C=20=EC=A4=84=EC=9D=84=20=EB=B0=94=EA=BF=94=20?= =?UTF-8?q?=EB=B3=B8=20=EC=9E=90=EB=A6=AC=20(=EB=84=A4=20=EC=A4=84=20?= =?UTF-8?q?=EB=81=9D)=20=EC=97=90=EC=84=9C=20=EB=82=98=EB=88=94=20-=20?= =?UTF-8?q?=EB=84=93=EC=9D=80=20=EA=B8=80=EC=9E=90=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=8F=AD=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=EA=B8=80?= =?UTF-8?q?=EA=BC=B4=EC=97=90=EC=84=9C=EB=8F=84=20=EC=A4=84=20=EB=81=9D?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=EC=A7=80=20=EC=95=8A=EA=B2=8C=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (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 --- .../windows_textview_shot_test.dart | 51 ++++++++++ lib/ui/text_viewer.dart | 98 +++++++++++++++---- test/xplore_tools_test.dart | 38 +++++++ 3 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 integration_test/windows_textview_shot_test.dart diff --git a/integration_test/windows_textview_shot_test.dart b/integration_test/windows_textview_shot_test.dart new file mode 100644 index 0000000..d1f7638 --- /dev/null +++ b/integration_test/windows_textview_shot_test.dart @@ -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.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + await t.runAsync(() => Future.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()); + }); + } + } +} diff --git a/lib/ui/text_viewer.dart b/lib/ui/text_viewer.dart index 108c6ad..c3c6d98 100644 --- a/lib/ui/text_viewer.dart +++ b/lib/ui/text_viewer.dart @@ -118,28 +118,61 @@ class _TextViewerPageState extends State { } /// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (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) { - _rowsText = _text; - _rowsPer = perRow; - _rows = viewerRows(_text, 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; + _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 { // 관리자 지시 (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 { } } -/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 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); - // 두 단위 글자 (이모지 등) 를 반으로 자르지 않게 - if (end < line.length && end > at + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--; + final byLayout = cut?.call(line, at); + if (byLayout != null && byLayout > at) { + var end = byLayout.clamp(at + 1, line.length); + // 두 단위 글자 (이모지 등) 를 반으로 자르지 않게 + 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); diff --git a/test/xplore_tools_test.dart b/test/xplore_tools_test.dart index dcf15e4..ee0ee57 100644 --- a/test/xplore_tools_test.dart +++ b/test/xplore_tools_test.dart @@ -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.delayed(const Duration(milliseconds: 30))); + await t.pump(); + } + final pieces = find.textContaining('한x한x'); + expect(pieces.evaluate().length, greaterThan(1)); + final first = t.renderObject(find.descendant(of: pieces.first, matching: find.byType(RichText))); + final txt = t.widget(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 = [];