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 = { /// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`` · `
` · `