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
parent
27baf91876
commit
47165371eb
|
|
@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -118,28 +118,61 @@ class _TextViewerPageState extends State<TextViewerPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (28 지적: 4줄마다 줄 끝이 비었다)
|
/// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (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(
|
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,
|
textDirection: TextDirection.ltr,
|
||||||
textScaler: MediaQuery.textScalerOf(context),
|
textScaler: MediaQuery.textScalerOf(context),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
)..layout();
|
)..layout();
|
||||||
final w = tp.width / 40;
|
final w = tp.width / 40;
|
||||||
tp.dispose();
|
tp.dispose();
|
||||||
return w > 0 ? w : _font * 0.62;
|
return w > 0 ? w : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 그릴 줄 (줄 번호 · 글) - 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다
|
/// 그릴 줄 (줄 번호 · 글) - 글 · 폭 · 글자 크기가 같으면 다시 만들지 않는다
|
||||||
List<(int, String)> _rows = const [];
|
List<(int, String)> _rows = const [];
|
||||||
String? _rowsText;
|
String? _rowsText;
|
||||||
int _rowsPer = 0;
|
String _rowsKey = '';
|
||||||
|
|
||||||
List<(int, String)> _rowsFor(int perRow) {
|
/// [room] 폭에 [style] 로 그릴 때의 줄. 아주 긴 줄은 화면에서 실제로 네 줄이 끝나는 자리 (TextPainter 로 그 글꼴 그대로 줄을 바꿔 봄) 에서 자른다 -
|
||||||
if (!identical(_rowsText, _text) || _rowsPer != perRow) {
|
/// 관리자 지시 (10/19): Windows 의 'monospace' 는 고정폭이 아니라 글자 수로 세면 넷째 줄 끝이 비었다 (영문 · 한글 섞인 줄 모두)
|
||||||
_rowsText = _text;
|
List<(int, String)> _rowsFor(BuildContext context, double room) {
|
||||||
_rowsPer = perRow;
|
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'));
|
||||||
_rows = viewerRows(_text, perRow);
|
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;
|
return _rows;
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +252,7 @@ class _TextViewerPageState extends State<TextViewerPage> {
|
||||||
// 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 -
|
// 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 -
|
||||||
// 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다
|
// 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다
|
||||||
final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0);
|
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(
|
return Scrollbar(
|
||||||
controller: _scroll,
|
controller: _scroll,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
|
@ -263,12 +296,14 @@ class _TextViewerPageState extends State<TextViewerPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 [perRow] 자가 들 때,
|
/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 고정폭 글자 [perRow] 개가 들 때,
|
||||||
/// 그보다 8배 넘게 긴 줄은 4배 (화면 네 줄쯤) 씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈)
|
/// 그보다 8배 넘게 긴 줄은 화면 네 줄 분량씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈).
|
||||||
List<(int, String)> viewerRows(String text, int perRow) {
|
/// 넓은 글자 (한글 · 한자 · 가나 · 전각 · 이모지) 는 고정폭 글자 [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 out = <(int, String)>[];
|
||||||
final lines = text.split('\n');
|
final lines = text.split('\n');
|
||||||
final piece = perRow * 4;
|
final budget = perRow * 4.0;
|
||||||
for (var i = 0; i < lines.length; i++) {
|
for (var i = 0; i < lines.length; i++) {
|
||||||
final line = lines[i];
|
final line = lines[i];
|
||||||
if (line.length <= perRow * 8) {
|
if (line.length <= perRow * 8) {
|
||||||
|
|
@ -277,12 +312,39 @@ List<(int, String)> viewerRows(String text, int perRow) {
|
||||||
}
|
}
|
||||||
var at = 0;
|
var at = 0;
|
||||||
while (at < line.length) {
|
while (at < line.length) {
|
||||||
var end = (at + piece).clamp(0, line.length);
|
final byLayout = cut?.call(line, at);
|
||||||
// 두 단위 글자 (이모지 등) 를 반으로 자르지 않게
|
if (byLayout != null && byLayout > at) {
|
||||||
if (end < line.length && end > at + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--;
|
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)));
|
out.add((at == 0 ? i + 1 : 0, line.substring(at, end)));
|
||||||
at = end;
|
at = end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||||
|
|
@ -105,6 +106,43 @@ void main() {
|
||||||
expect(bar.controller!.hasClients, isTrue);
|
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 {
|
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