From 94ec659b785d4844b0a596adfa29981e38140c89 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 12 Nov 2026 08:10:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(flutter):=20=EB=B3=B8=EB=AC=B8=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EC=B2=A8=EB=B6=80=EB=A7=8C=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=EC=97=90=EC=84=9C=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=95=84=EB=9E=98=EA=B0=80=20=EB=B9=84?= =?UTF-8?q?=EA=B3=A0=20=EC=B2=A8=EB=B6=80=EA=B0=80=20=EC=9E=98=EB=A6=AC?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽기 화면은 머리(제목 · 보낸 사람 · 안내 · 첨부)가 남은 높이의 절반까지만 쓰고 나머지는 본문 WebView 였다. 본문이 비면 아래 절반이 빈 칸으로 남고 첨부가 많으면 위 절반 안에서 잘렸다(11-12 배포 뒤 사용자 보고). 정화한 본문에 보이는 것(글자 · 주소가 남은 그림 · hr · li · details · q)이 없으면 상태 bodyEmpty 로 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다. 판정은 정화와 같은 길에서 내고(큰 본문은 isolate), 아래 막대가 없는 예약 메일은 머리 스크롤에 시스템 아래 여백을 더한다. 본문이 있는 메일은 그대로다. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/utils/visible_text.dart | 56 +++++ .../lib/features/mail/domain/mail_html.dart | 69 ++++-- .../pages/message_reader_page.dart | 47 +++- .../providers/message_reader_controller.dart | 3 + .../providers/message_reader_state.dart | 7 + .../presentation/widgets/mail_body_view.dart | 1 + .../test/core/utils/visible_text_test.dart | 111 +++++++++ .../test/features/mail/mail_html_test.dart | 135 +++++++++++ .../mail/message_reader_page_test.dart | 227 ++++++++++++++++++ 9 files changed, 625 insertions(+), 31 deletions(-) create mode 100644 flutter/lib/core/utils/visible_text.dart create mode 100644 flutter/test/core/utils/visible_text_test.dart diff --git a/flutter/lib/core/utils/visible_text.dart b/flutter/lib/core/utils/visible_text.dart new file mode 100644 index 0000000..e7d151c --- /dev/null +++ b/flutter/lib/core/utils/visible_text.dart @@ -0,0 +1,56 @@ +/// 글에 보이는 글자가 있는가 — 순수 함수 (모듈 공통 — 메일 본문이 비었는지 등). +/// +/// 아래 글자만 있는 글은 보이는 것이 없다(빈 글이다): +/// - 공백: Dart `trim()` 이 지우는 공백 전부 — 빈칸 · 탭 · 줄바꿈 · nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등 +/// - 제어 문자: C0 · DEL · C1 +/// - 보이지 않는 글자: 유니코드의 Default_Ignorable_Code_Point — 제로폭 빈칸 · 이음(ZWNJ · ZWJ · WORD JOINER) · 방향 표시(LRM · RLM · 끼워 넣기 · 격리) · +/// soft hyphen · 이체 선택자 · 한글 채움 문자(U+115F · U+1160 · U+3164 · U+FFA0) · 태그 글자 등 +/// - 점자 빈칸(U+2800) — 글자이지만 빈칸으로 그려진다 +library; + +/// [text] 에 보이는 글자가 하나라도 있는가 — 앞에서부터 보고 처음 보이는 글자에서 멈춘다(길이에 비례) +bool hasVisibleText(String text) { + for (final int rune in text.runes) { + if (!_isInvisible(rune)) return true; + } + return false; +} + +/// 보이지 않는 글자인가 — 위 머리말의 목록 +bool _isInvisible(int rune) { + // C0 제어 문자 · 빈칸 + if (rune <= 0x20) return true; + // 그 밖의 ASCII 는 모두 보인다 + if (rune < 0x7F) return false; + // DEL · C1 제어 문자(NEL 포함) · nbsp + if (rune <= 0xA0) return true; + for (final (int first, int last) in _invisibleRanges) { + if (rune < first) return false; + if (rune <= last) return true; + } + return false; +} + +/// 보이지 않는 글자의 범위 — (처음, 끝) 끝 포함 · 작은 차례대로(U+00A0 까지는 위에서 따로 본다) +const List<(int, int)> _invisibleRanges = <(int, int)>[ + (0x00AD, 0x00AD), // soft hyphen + (0x034F, 0x034F), // 글자 묶음 이음(CGJ) + (0x061C, 0x061C), // 아랍 글자 방향 표시 + (0x115F, 0x1160), // 한글 초성 · 중성 채움 문자 + (0x1680, 0x1680), // 오검 빈칸 + (0x17B4, 0x17B5), // 크메르의 소리 없는 모음 + (0x180B, 0x180F), // 몽골 이체 선택자 · 모음 구분 + (0x2000, 0x200F), // 너비가 다른 빈칸 · 제로폭 빈칸 · 이음 · 방향 표시 + (0x2028, 0x202F), // 줄 · 문단 구분 · 방향 끼워 넣기 · 좁은 nbsp + (0x205F, 0x206F), // 수학 빈칸 · WORD JOINER · 보이지 않는 연산자 · 방향 격리 + (0x2800, 0x2800), // 점자 빈칸 + (0x3000, 0x3000), // 전각 빈칸 + (0x3164, 0x3164), // 한글 채움 문자 + (0xFE00, 0xFE0F), // 이체 선택자 + (0xFEFF, 0xFEFF), // BOM(제로폭 nbsp) + (0xFFA0, 0xFFA0), // 반각 한글 채움 문자 + (0xFFF0, 0xFFF8), // 보이지 않게 정해 둔 빈 자리 + (0x1BCA0, 0x1BCA3), // 속기 서식 제어 + (0x1D173, 0x1D17A), // 악보 서식 제어 + (0xE0000, 0xE0FFF), // 태그 글자 · 이체 선택자 보충 +]; diff --git a/flutter/lib/features/mail/domain/mail_html.dart b/flutter/lib/features/mail/domain/mail_html.dart index 694f160..da2bbdc 100644 --- a/flutter/lib/features/mail/domain/mail_html.dart +++ b/flutter/lib/features/mail/domain/mail_html.dart @@ -11,8 +11,11 @@ /// /// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 — /// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다. +/// +/// 정화하며 본문에 보이는 것이 있는지도 함께 본다([SanitizedMailBody.hasVisibleContent]) — 없으면 읽기 화면이 본문 칸을 그리지 않는다. library; +import 'package:arasystem_app/core/utils/visible_text.dart'; import 'package:arasystem_app/features/mail/domain/mail_images.dart'; import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:html/dom.dart' as dom; @@ -67,6 +70,11 @@ const Set _droppedWithContent = { /// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다 const Set _voidTags = {'br', 'col', 'hr', 'img', 'wbr'}; +/// 남기는 태그 가운데 글 없이도 그려지는 것 — 가로줄 · 목록 표시(• · 1.) · 펼침 표시(▶ 와 기본 이름) · 따옴표. +/// 그림(`img`)은 주소가 남았을 때만 보인다(주소를 지운 그림은 문서가 숨긴다 — [buildMailDocument]) — 따로 본다. +/// 모양 속성(style · bgcolor · border)만으로 그린 상자 · 선 · 바탕과 빈 인용의 왼쪽 막대는 보이는 것으로 치지 않는다 — 글도 그림도 없는 꾸밈뿐이다 +const Set _drawnWithoutText = {'hr', 'li', 'details', 'q'}; + /// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다 const Set _globalAttributes = { 'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', // @@ -109,6 +117,7 @@ class SanitizedMailBody { const SanitizedMailBody({ required this.fragment, required this.proxiedTokens, + required this.hasVisibleContent, this.htmlOmitted = false, }); @@ -118,6 +127,12 @@ class SanitizedMailBody { /// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만) final List proxiedTokens; + /// 본문에 보이는 것이 있다 — 공백 · 보이지 않는 글자가 아닌 글자([hasVisibleText]), 주소가 남은 그림, 글 없이도 그려지는 태그 + /// (가로줄 · 목록 표시 · 펼침 표시 · 따옴표). 거짓이면 읽기 화면이 본문 칸(WebView)을 그리지 않는다. + /// + /// 중계 그림은 받기 전에도 보이는 것으로 친다 — 하나도 받지 못하면 빈 본문 칸이 남는다(본문을 다시 보지 않는다) + final bool hasVisibleContent; + /// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다 final bool htmlOmitted; } @@ -130,7 +145,10 @@ String? displayedHtmlBody(String? htmlBody) => /// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다). /// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다. /// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `` 로 넣는다 -/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다) +/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다). +/// +/// 고른 본문에 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 함께 낸다 — 고르는 규칙은 그대로다(서식 본문이 비어 보여도 +/// 글자 본문으로 바꾸지 않는다 — 웹 `displayedHtmlBody` 와 같은 본문을 보인다) SanitizedMailBody mailBodyFragment({ required String? htmlBody, required String? textBody, @@ -162,6 +180,7 @@ SanitizedMailBody _textFragment( fragment: '
${highlightedTextHtml(textBody ?? '', highlights)}
', proxiedTokens: const [], + hasVisibleContent: hasVisibleText(textBody ?? ''), htmlOmitted: htmlOmitted, ); @@ -316,15 +335,22 @@ const Set _htmlVoidTags = { /// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`` · `
` · `