Spaces:
Sleeping
Sleeping
| # wm_constants.py — StealthMark 상수·사전·스타일·HTML 헬퍼 모음 | |
| # app.py에서 `from wm_constants import *` 로 임포트 | |
| import html as html_lib, re, unicodedata | |
| from difflib import SequenceMatcher | |
| # ─── Unicode 워터마크 문자 집합 ─────────────────────────────────────────────── | |
| ZW_CHARS = {'0': '\u200B', '1': '\u200C'} | |
| ZW_START = '\uFEFF' | |
| ZW_END = '\u200D' | |
| ALL_ZW = {'\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF'} | |
| ZW_NAMES = { | |
| '\u200B': ('ZWSP', '#b91c1c'), # 빨강 — ZWSP (L1=0) | |
| '\u200C': ('ZWNJ', '#065f46'), # 초록 — ZWNJ (L1=1) | |
| '\u200D': ('ZWJ', '#1d4ed8'), # 파랑 — ZWJ | |
| '\u2060': ('WJ', '#0d9488'), # 틸 — Word Joiner | |
| '\uFEFF': ('BOM', '#854d0e'), # 황갈 — BOM | |
| } | |
| VS_CHARS = [chr(0xFE00 + i) for i in range(16)] | |
| CGJ = '\u034F' | |
| ALL_MICRO = set(VS_CHARS) | {CGJ} | |
| PUNCT_TARGETS = set('.,!?;:·') | |
| # ─── 샘플 텍스트 ────────────────────────────────────────────────────────────── | |
| SAMPLE_TEXT_KO = ( | |
| "이라크 쿠르드군 수천 명이 이란에 진입해 지상전을 벌이고 있다는 보도가 나왔다. " | |
| "미국 폭스뉴스는 4일(현지시간) 미국 정부 관계자를 인용해 이라크에 거주해온 이란 쿠르드족이 " | |
| "이번 공격 작전의 일환으로 이란 북서부로 향하고 있다고 보도했다. " | |
| "이들은 이란 정권에 맞서는 이란계 쿠르드 민병대인 것으로 전해졌다. " | |
| "이날 이스라엘 매체 타임스오브이스라엘도 이라크에 주둔 중이던 쿠르드 민병대가 이란 국경을 넘어 공격을 개시했다고 보도했다. " | |
| "이스라엘 정부 당국자는 '우리(이스라엘)는 서부 이란에서 활동하는 쿠르드 민병대를 지원하고 있다'며 " | |
| "이들이 이란 정권에 도전하도록 해 더 큰 봉기를 유도하는 게 작전의 목표라고 밝혔다. " | |
| "이라크 쿠르드족 반군은 반(反)이란 세력 중 가장 조직력이 큰 곳으로 평가된다. " | |
| "다만 해당 내용과 상반된 정보도 나오고 있다. " | |
| "캐럴라인 레빗 백악관 대변인은 이날 열린 브리핑에서 '트럼프 대통령의 쿠르드족 세력 접촉이 " | |
| "이란의 체제 전복을 위해 무장세력을 지원하기 위한 것'이라는 언론 보도에 대해서 " | |
| "'대통령이 그런 계획에 동의했다는 것은 전혀 사실이 아니다'고 부인했다. " | |
| "앞서 미 월스트리트저널(WSJ)은 전날 트럼프 대통령이 1일 쿠르드족 지도자들과 접촉했으며, " | |
| "이들 무장세력에 무기 및 군사훈련 지원과 정보 지원을 할지와 관련해 최종 결정을 내리지는 않았다고 보도했다. " | |
| "이란 반관영 타스님통신도 이라크 쿠르드군이 무장한 채 이란으로 진입, " | |
| "지상 공격을 시작했다는 보도에 대해 '사실이 아니다'라고 전했다." | |
| ) | |
| SAMPLE_TEXT_EN = ( | |
| "The technology sector witnessed significant developments as major artificial intelligence companies " | |
| "announced groundbreaking partnerships. Industry leaders emphasized the importance of responsible AI " | |
| "development during the annual summit held in San Francisco. Several prominent researchers highlighted " | |
| "that current AI systems still face substantial challenges in areas such as reliability, safety, and " | |
| "ethical deployment. The conference attracted participants from over 40 countries, marking record " | |
| "attendance. Leading companies presented their latest innovations in content protection, watermarking " | |
| "technology, and digital rights management. Experts warned that without proper safeguards, AI-generated " | |
| "content could undermine trust in digital media. The summit concluded with a joint declaration calling " | |
| "for international cooperation on AI governance standards." | |
| ) | |
| SAMPLE_TEXT = SAMPLE_TEXT_KO | |
| # ─── 한국어 언어 사전 ───────────────────────────────────────────────────────── | |
| KO_SYNONYM = { | |
| '발': ['구축', '제작', '설계', '구현'], | |
| '기술': ['테크놀로지', '방법론', '솔루션', '기법'], | |
| '보호': ['방어', '수호', '보전', '보장'], | |
| '사용': ['활용', '이용', '적용', '운용'], | |
| '콘텐츠': ['컨텐츠', '자료', '내용물'], | |
| '데이터': ['자료', '정보', '데이타'], | |
| '플랫폼': ['시스템', '서비스', '환경'], | |
| '침해': ['위반', '훼손', '침범'], | |
| '입증': ['증명', '검증', '확인', '규명'], | |
| '대응': ['대처', '조치', '방안'], | |
| '확보': ['마련', '구축', '수립'], | |
| '실증': ['검증', '실험', '시범'], | |
| '성과': ['결과', '실적', '성취'], | |
| '의미': ['의의', '중요성', '가치'], | |
| } | |
| KO_ENDING = { | |
| '한다': ['하고 있다', '하게 된다'], | |
| '했다': ['하였다', '한 바 있다'], | |
| '된다': ['이뤄진다', '이루어진다'], | |
| '이다': ['에 해당한다'], | |
| '졌다': ['되었다'], | |
| '않다': ['아니하다'], | |
| } | |
| KO_CONNECTIVE = { | |
| '하지만': ['그러나', '다만', '그렇지만'], | |
| '또한': ['아울러', '더불어'], | |
| '따라서': ['이에 따라', '그러므로'], | |
| '때문에': ['탓에', '까닭에'], | |
| '위해': ['위하여', '목적으로'], | |
| '통해': ['통하여', '거쳐'], | |
| } | |
| KO_PARTICLE = { | |
| '에서는': ['에서', '에선'], | |
| '으로는': ['으로', '으론'], | |
| '이라고': ['라고', '이라'], | |
| } | |
| # ─── 영어 언어 사전 ─────────────────────────────────────────────────────────── | |
| EN_SYNONYM = { | |
| 'development': ['construction', 'creation', 'design', 'implementation'], | |
| 'technology': ['methodology', 'solution', 'technique', 'approach'], | |
| 'protection': ['defense', 'safeguard', 'preservation', 'security'], | |
| 'important': ['significant', 'crucial', 'essential', 'vital'], | |
| 'content': ['material', 'data', 'information'], | |
| 'analysis': ['examination', 'assessment', 'evaluation'], | |
| 'detect': ['identify', 'discover', 'find', 'recognize'], | |
| 'system': ['platform', 'framework', 'infrastructure'], | |
| 'evidence': ['proof', 'documentation', 'verification'], | |
| 'attack': ['threat', 'intrusion', 'compromise'], | |
| 'improve': ['enhance', 'optimize', 'strengthen', 'refine'], | |
| 'create': ['generate', 'produce', 'develop', 'establish'], | |
| 'reduce': ['decrease', 'minimize', 'lower', 'diminish'], | |
| 'increase': ['boost', 'expand', 'elevate', 'amplify'], | |
| 'provide': ['supply', 'deliver', 'offer', 'furnish'], | |
| 'maintain': ['sustain', 'preserve', 'uphold', 'retain'], | |
| 'demonstrate': ['illustrate', 'showcase', 'exhibit', 'display'], | |
| 'implement': ['execute', 'deploy', 'carry out', 'apply'], | |
| 'require': ['demand', 'necessitate', 'need', 'call for'], | |
| 'significant': ['substantial', 'considerable', 'notable', 'meaningful'], | |
| } | |
| EN_CONNECTIVE = { | |
| 'however': ['nevertheless', 'nonetheless', 'yet'], | |
| 'also': ['additionally', 'furthermore', 'moreover'], | |
| 'therefore': ['consequently', 'thus', 'hence'], | |
| 'because': ['since', 'as', 'due to'], | |
| 'although': ['though', 'even though', 'while'], | |
| 'meanwhile': ['in the meantime', 'at the same time', 'concurrently'], | |
| 'specifically': ['in particular', 'notably', 'especially'], | |
| 'instead': ['alternatively', 'rather', 'in place of'], | |
| 'similarly': ['likewise', 'in the same way', 'equally'], | |
| 'ultimately': ['in the end', 'eventually', 'finally'], | |
| } | |
| EN_ENDING = { | |
| 'is important': ['matters greatly', 'is significant', 'is crucial'], | |
| 'was announced': ['has been revealed', 'was disclosed', 'was reported'], | |
| 'will be': ['is expected to be', 'shall be'], | |
| 'have been': ['were', 'had been'], | |
| 'is used': ['is employed', 'is utilized', 'is applied'], | |
| 'can be': ['may be', 'is able to be', 'could be'], | |
| 'should be': ['ought to be', 'needs to be', 'must be'], | |
| 'has shown': ['has demonstrated', 'has revealed', 'has indicated'], | |
| 'is known': ['is recognized', 'is acknowledged', 'is understood'], | |
| 'was developed': ['was created', 'was designed', 'was built'], | |
| } | |
| # ─── 영어 전치사·접속사·관계사 집합 ────────────────────────────────────────── | |
| EN_PREP = { | |
| 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'to', 'of', 'about', | |
| 'into', 'through', 'during', 'before', 'after', 'between', 'under', | |
| 'above', 'below', 'against', 'among', 'within', 'without', 'toward', | |
| 'upon', 'across', 'along', 'behind', 'beyond', 'except', 'since', | |
| 'until', 'around', 'beside', 'beneath', 'despite', 'regarding', | |
| 'concerning', 'throughout', | |
| } | |
| EN_CONJ = { | |
| 'and', 'but', 'or', 'nor', 'yet', 'so', 'because', 'although', 'while', | |
| 'whereas', 'unless', 'since', 'however', 'therefore', 'moreover', | |
| 'furthermore', 'nevertheless', 'consequently', 'meanwhile', 'otherwise', | |
| 'hence', 'thus', 'accordingly', 'besides', 'nonetheless', 'alternatively', | |
| 'specifically', 'additionally', 'subsequently', | |
| } | |
| EN_REL = { | |
| 'that', 'which', 'who', 'whom', 'whose', 'where', 'when', 'while', | |
| 'if', 'whether', 'although', 'though', 'because', 'since', 'as', | |
| 'until', 'before', 'after', 'once', 'unless', 'whereas', 'whenever', | |
| 'wherever', | |
| } | |
| # ─── LLM 모델 목록 & 태스크 ─────────────────────────────────────────────────── | |
| GROQ_MODELS = [ | |
| ("openai/gpt-oss-120b", "GPT-OSS 120B"), | |
| ("openai/gpt-oss-20b", "GPT-OSS 20B"), | |
| ("qwen/qwen3-32b", "Qwen3 32B"), | |
| ("llama-3.3-70b-versatile", "LLaMA 3.3 70B"), | |
| ("meta-llama/llama-4-scout-17b-16e-instruct","LLaMA 4 Scout"), | |
| ("moonshotai/kimi-k2-instruct-0905", "Kimi K2"), | |
| ("llama-3.1-8b-instant", "LLaMA 3.1 8B"), | |
| ("gemma2-9b-it", "Gemma2 9B"), | |
| ("mixtral-8x7b-32768", "Mixtral 8x7B"), | |
| ("gpt-5.2", "GPT-5.2 (OpenAI)"), | |
| ] | |
| LLM_TASKS = { | |
| "📝 요약": | |
| "다음 글을 핵심 내용 위주로 3문장 이내로 요약해 주세요:\n\n", | |
| "🔄 패러프레이징": | |
| "다음 텍스트를 같은 의미를 유지하면서 다른 표현으로 재작성해 주세요:\n\n", | |
| "📖 확장": | |
| "다음 텍스트를 2배 분량으로 배경 설명을 추가하여 확장해 주세요:\n\n", | |
| "🌐 역번역": | |
| "다음 텍스트를 영어로 번역한 뒤 다시 한국어로 번역해 주세요:\n\n", | |
| "✂️ 핵심 발췌": | |
| "다음 텍스트에서 가장 중요한 내용만 추출해 주세요:\n\n", | |
| "🎭 문체 변환": | |
| "다음 글을 편안한 구어체 스타일로 재작성해 주세요:\n\n", | |
| "📋 글머리 정리": | |
| "다음 텍스트를 핵심 글머리 목록으로 정리해 주세요:\n\n", | |
| "🔀 콜라주 재작성": | |
| "다음 텍스트의 문장 순서를 바꾸고 일부를 합치거나 나누어 재구성해 주세요:\n\n", | |
| } | |
| # ─── 분석 모드 목록 ─────────────────────────────────────────────────────────── | |
| PLAG_MODES = [ | |
| "🔍 종합 분석 — 전체 표절 검사 (Comprehensive)", | |
| "📊 N-gram 정밀 — 어구 패턴 비교", | |
| "📝 Sentence 유사성 — 문장 1:1 비교", | |
| "🔀 Structure 분석 — 순서·배치 보존", | |
| "🧠 AI 재작성 탐지 — 패러프레이징 (AI Rewrite)", | |
| "✂️ Mosaic 표절 — 짜깁기 탐지", | |
| "📖 Excerpt 발췌 — 부분 인용 탐지", | |
| ] | |
| IMG_MODES = [ | |
| "🔍 종합 유사성 분석", | |
| "🔢 Hash 지각 해시 비교", | |
| "📐 SSIM 구조적 유사도", | |
| "🎨 색상 분포 히스토그램 비교", | |
| "🧩 특징점 엣지 매칭", | |
| ] | |
| VIDEO_SIM_MODES = [ | |
| "🔍 종합 유사성 분석", | |
| "🕐 DTW 시간축 동적 매칭", | |
| "🔢 Hash 키프레임 해시 비교", | |
| "📐 SSIM 구조적 유사도", | |
| ] | |
| # ─── 기본 인라인 스타일 상수 ────────────────────────────────────────────────── | |
| BOX = "font-family:'Pretendard','Inter','Noto Sans KR',sans-serif;background:#ffffff;color:#1e293b;padding:20px;border-radius:12px;border:1px solid #e2e8f0;box-shadow:0 2px 12px rgba(99,102,241,.06);" | |
| BOX_IN = "background:#f0f4ff;color:#1e293b;border-radius:8px;padding:12px;margin-bottom:8px;border:1px solid #e2e8f0;" | |
| _CARD = "text-align:center;padding:14px;background:#f4f7ff;border-radius:12px;border:1px solid #e2e8f0;" | |
| # ─── Gradio 라이트 테마 CSS (index.html 색상 기반) ────────────────────────── | |
| DARK_CSS = ( | |
| # ── 폰트 임포트 | |
| "@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.min.css');" | |
| # ── 전체 배경·폰트 | |
| "body,.gradio-container,.main,.app,.contain,div[class*='wrap'],div.app" | |
| "{background:#f4f7ff!important;font-family:'Pretendard',-apple-system,sans-serif!important;max-width:100%!important;}" | |
| # ── 스크롤바 | |
| "*{scrollbar-width:thin;scrollbar-color:#c7d2e0 #f4f7ff;}" | |
| "::-webkit-scrollbar{width:6px;height:6px;}" | |
| "::-webkit-scrollbar-track{background:#f4f7ff;}" | |
| "::-webkit-scrollbar-thumb{background:#c7d2e0;border-radius:3px;}" | |
| # ── 푸터 숨기기 | |
| "footer,.footer,footer.svelte-1ax1toq{display:none!important;}" | |
| # ── 탭 네비게이션 | |
| ".tab-nav,.tabs>.tab-nav,div[class*='tab-nav'],div[role='tablist'],.tabitem>.tab-nav," | |
| "div.tab-nav{background:linear-gradient(180deg,#ffffff,#f0f4ff)!important;" | |
| "border-bottom:2px solid #e2e8f0!important;border-radius:16px 16px 0 0!important;" | |
| "padding:8px 10px!important;gap:6px!important;display:flex!important;flex-wrap:wrap!important;" | |
| "box-shadow:0 1px 8px rgba(99,102,241,.06)!important;}" | |
| # ── 탭 버튼 기본 | |
| ".tab-nav button,.tab-nav>button,div[class*='tab-nav'] button,div[role='tablist'] button," | |
| "div[role='tablist']>button,button[role='tab'],.tab-nav button:not(.selected)" | |
| "{color:#475569!important;font-weight:700!important;font-size:14px!important;" | |
| "border:1px solid #e2e8f0!important;border-radius:10px!important;" | |
| "padding:11px 18px!important;background:#ffffff!important;" | |
| "transition:all .25s ease!important;opacity:1!important;" | |
| "letter-spacing:-.2px!important;}" | |
| # ── 탭 버튼 hover | |
| ".tab-nav button:hover,div[role='tablist'] button:hover,button[role='tab']:hover" | |
| "{color:#1e293b!important;background:rgba(0,184,148,.06)!important;" | |
| "border-color:rgba(0,184,148,.3)!important;}" | |
| # ── 탭 버튼 선택 | |
| ".tab-nav button.selected,div[role='tablist'] button[aria-selected='true']," | |
| "button[role='tab'][aria-selected='true'],.tab-nav button.selected:hover," | |
| "div[class*='tab-nav'] button.selected" | |
| "{color:#00b894!important;font-weight:900!important;" | |
| "background:linear-gradient(135deg,rgba(0,184,148,.1),rgba(124,58,237,.06))!important;" | |
| "border-color:rgba(0,184,148,.35)!important;" | |
| "box-shadow:0 2px 12px rgba(0,184,148,.12)!important;}" | |
| # ── 탭 패널 | |
| ".tabitem,div[class*='tabitem'],div[role='tabpanel']{background:#f4f7ff!important;" | |
| "border:1px solid #e2e8f0!important;border-top:none!important;" | |
| "border-radius:0 0 14px 14px!important;padding:24px!important;}" | |
| # ── 블록 | |
| ".block,div.block{background:#ffffff!important;border:1px solid #e2e8f0!important;" | |
| "border-radius:12px!important;transition:border-color .3s,box-shadow .3s!important;" | |
| "box-shadow:0 2px 8px rgba(99,102,241,.04)!important;}" | |
| ".block:focus-within{border-color:rgba(0,184,148,.4)!important;" | |
| "box-shadow:0 0 12px rgba(0,184,148,.08)!important;}" | |
| # ── 레이블 | |
| "label,span.label-wrap,.label-wrap>span,label>span,.label-wrap,.input-label," | |
| "span[data-testid='block-label']{color:#1e293b!important;font-weight:700!important;font-size:14px!important;}" | |
| # ── textarea·input | |
| "textarea,input[type=text],input[type=password],input[type=number],textarea.scroll-hide," | |
| ".textbox textarea,.textbox input,input.border-none,div[data-testid='textbox'] textarea," | |
| "div[data-testid='password'] input{background:#ffffff!important;color:#1e293b!important;" | |
| "border-color:#1e293b!important;border-radius:10px!important;font-size:15px!important;" | |
| "caret-color:#00b894!important;line-height:1.6!important;}" | |
| "textarea::placeholder,input::placeholder,.textbox textarea::placeholder," | |
| ".textbox input::placeholder{color:#64748b!important;font-size:14px!important;}" | |
| "textarea:focus,input:focus,input[type=password]:focus{border-color:rgba(0,184,148,.5)!important;" | |
| "box-shadow:0 0 10px rgba(0,184,148,.08)!important;color:#1e293b!important;}" | |
| # ── mono textarea (로그) | |
| ".mono textarea{font-family:'D2Coding','Consolas','Courier New',monospace!important;" | |
| "font-size:13px!important;color:#1e293b!important;background:#f8fafc!important;line-height:1.7!important;" | |
| "border:1px solid #e2e8f0!important;}" | |
| # ── 드롭다운 | |
| ".dropdown,.dropdown-container,select,div[class*='dropdown'],div[data-testid='dropdown']," | |
| ".dropdown input,button[class*='dropdown']{background:#ffffff!important;color:#1e293b!important;" | |
| "border-color:#1e293b!important;border-radius:10px!important;font-size:14px!important;font-weight:600!important;}" | |
| "ul[role='listbox']{background:#ffffff!important;border-color:#1e293b!important;border-radius:10px!important;box-shadow:0 4px 16px rgba(99,102,241,.1)!important;}" | |
| "ul[role='listbox'] li{color:#1e293b!important;font-size:14px!important;}" | |
| "ul[role='listbox'] li:hover{background:rgba(0,184,148,.06)!important;}" | |
| # ── Primary 버튼 (민트 그라디언트) | |
| ".primary,.btn-primary,button.primary,button.lg.primary,.gradio-button.primary" | |
| "{background:linear-gradient(135deg,#00b894,#7c3aed)!important;color:#ffffff!important;" | |
| "font-weight:900!important;border:none!important;border-radius:12px!important;" | |
| "box-shadow:0 4px 16px rgba(0,184,148,.25)!important;font-size:15px!important;" | |
| "letter-spacing:-.3px!important;transition:all .3s!important;}" | |
| ".primary:hover,button.primary:hover{box-shadow:0 6px 24px rgba(0,184,148,.4)!important;" | |
| "transform:translateY(-1px)!important;}" | |
| # ── Secondary 버튼 | |
| ".secondary,.btn-secondary,button.secondary,.gradio-button.secondary" | |
| "{background:#ffffff!important;color:#475569!important;" | |
| "font-weight:700!important;border:1px solid #e2e8f0!important;border-radius:12px!important;" | |
| "box-shadow:0 1px 4px rgba(99,102,241,.06)!important;}" | |
| ".secondary:hover,button.secondary:hover{background:#f0f4ff!important;color:#1e293b!important;border-color:rgba(0,184,148,.3)!important;}" | |
| # ── 아코디언 | |
| ".accordion,div[class*='accordion']{background:#ffffff!important;border-color:#1e293b!important;border-radius:12px!important;}" | |
| ".accordion .label-wrap{color:#1e293b!important;font-weight:700!important;}" | |
| # ── 헤딩·텍스트 | |
| "h1,h2,h3,.markdown h1,.markdown h2,.markdown h3{color:#1e293b!important;font-weight:800!important;}" | |
| ".markdown,.prose,p,.block p,.block span{color:#475569!important;}" | |
| ".block .wrap,.info,.hint,div[class*='description'],.block .info{color:#64748b!important;font-size:13px!important;}" | |
| # ── 슬라이더 | |
| "input[type=range]{accent-color:#00b894!important;}" | |
| ".slider{--slider-color:#00b894!important;}" | |
| ".range-slider input{accent-color:#00b894!important;}" | |
| # ── 프로그레스바 | |
| ".progress-bar,.progress-bar>div{background:linear-gradient(90deg,#00b894,#7c3aed)!important;border-radius:4px!important;}" | |
| ".progress-text{color:#1e293b!important;}" | |
| # ── 라디오 그룹 | |
| ".radio-group,.wrap[data-testid='radio-group'],div[class*='radio'],fieldset[class*='radio']," | |
| "div.radio-group{background:#f4f7ff!important;border-radius:12px!important;border:none!important;}" | |
| ".radio-group label,.wrap[data-testid='radio-group'] label,div[class*='radio'] label," | |
| "div[class*='radio'] label span,fieldset label,input[type='radio']+label," | |
| "input[type='radio']~label,input[type='radio']~span,.gradio-radio label," | |
| "label[data-testid='radio-label'],div[role='radiogroup'] label,div[role='radiogroup'] div" | |
| "{color:#475569!important;font-size:14px!important;font-weight:600!important;" | |
| "background:#ffffff!important;border:1px solid #e2e8f0!important;border-radius:8px!important;" | |
| "padding:10px 14px!important;transition:all .2s!important;cursor:pointer!important;}" | |
| ".radio-group label:hover,div[class*='radio'] label:hover,div[role='radiogroup'] label:hover," | |
| "div[role='radiogroup'] div:hover{background:#f0f4ff!important;color:#1e293b!important;" | |
| "border-color:rgba(0,184,148,.3)!important;}" | |
| "input[type='radio']:checked+label,input[type='radio']:checked~label," | |
| "input[type='radio']:checked~span,.radio-group label.selected," | |
| "div[class*='radio'] label.selected,div[role='radiogroup'] label[data-checked='true']," | |
| "div[role='radiogroup'] div[data-checked],label:has(input[type='radio']:checked)," | |
| "div[class*='radio'] label:has(input:checked)" | |
| "{background:rgba(0,184,148,.08)!important;color:#00b894!important;font-weight:700!important;" | |
| "border-color:rgba(0,184,148,.35)!important;box-shadow:0 0 10px rgba(0,184,148,.08)!important;}" | |
| "input[type='radio']{accent-color:#00b894!important;}" | |
| ".info,span[class*='info'],.block .info,.gradio-container .info{color:#64748b!important;font-size:12px!important;}" | |
| "@media(max-width:768px){.tab-nav button,div[role='tablist'] button{padding:8px 10px!important;font-size:11px!important;}}" | |
| ) | |
| # ─── Gradio head 인젝션 (HF 헤더 숨기기 + 라이트 탭 강제 스타일) ────────────── | |
| HEAD_INJECT = ( | |
| "<style>" | |
| "@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.min.css');" | |
| "header,.built-with{display:none!important}" | |
| "body,.gradio-container{background:#f4f7ff!important;font-family:'Pretendard',-apple-system,sans-serif!important;}" | |
| "div[role=\"tablist\"]>button,.tab-nav>button,.tabs .tab-nav button" | |
| "{color:#475569!important;font-size:14px!important;font-weight:700!important;" | |
| "background:#ffffff!important;border:1px solid #e2e8f0!important;" | |
| "border-radius:10px!important;padding:11px 18px!important;opacity:1!important;}" | |
| "div[role=\"tablist\"]>button:hover,.tab-nav>button:hover" | |
| "{color:#1e293b!important;background:rgba(0,184,148,.06)!important;" | |
| "border-color:rgba(0,184,148,.3)!important;}" | |
| "div[role=\"tablist\"]>button[aria-selected=\"true\"],.tab-nav>button.selected" | |
| "{color:#00b894!important;font-weight:900!important;" | |
| "background:linear-gradient(135deg,rgba(0,184,148,.1),rgba(124,58,237,.06))!important;" | |
| "border-color:rgba(0,184,148,.35)!important;" | |
| "box-shadow:0 2px 10px rgba(0,184,148,.12)!important;}" | |
| "textarea,input[type=text],input[type=password],input[type=number]" | |
| "{color:#1e293b!important;background:#ffffff!important;font-size:15px!important;border-color:#1e293b!important;}" | |
| "textarea::placeholder,input::placeholder{color:#64748b!important;}" | |
| "label,span[data-testid=\"block-label\"],.label-wrap span{color:#1e293b!important;font-weight:700!important;}" | |
| "div[role=\"radiogroup\"] label,div[role=\"radiogroup\"] div,div[class*=\"radio\"] label," | |
| "fieldset label,.radio-group label{color:#475569!important;background:#ffffff!important;" | |
| "border:1px solid #e2e8f0!important;}" | |
| "input[type=\"radio\"]:checked+label,input[type=\"radio\"]:checked~label," | |
| "label:has(input[type=\"radio\"]:checked),div[class*=\"radio\"] label.selected," | |
| "div[role=\"radiogroup\"] label[data-checked=\"true\"]" | |
| "{color:#00b894!important;background:rgba(0,184,148,.08)!important;" | |
| "border-color:rgba(0,184,148,.35)!important;}" | |
| "</style>" | |
| ) | |
| # ─── 탭·섹션 HTML 헬퍼 ─────────────────────────────────────────────────────── | |
| def _tab_hdr(icon: str, color: str, title: str, desc: str) -> str: | |
| """최상위 탭 헤더 HTML 생성 (color = 'r,g,b' 형식)""" | |
| return ( | |
| f'<div style="background:linear-gradient(135deg,rgba({color},.07),rgba(124,58,237,.03));' | |
| f'border:1px solid rgba({color},.2);border-radius:14px;padding:14px 20px;margin-bottom:12px;' | |
| f'box-shadow:0 2px 8px rgba(99,102,241,.06);">' | |
| f'<div style="display:flex;align-items:center;gap:12px;">' | |
| f'<span style="font-size:28px;">{icon}</span>' | |
| f'<div><div style="color:rgb({color});font-weight:800;font-size:16px;">{title}</div>' | |
| f'<div style="color:#94a3b8;font-size:12px;padding:4px 0;">{desc}</div>' | |
| f'</div></div></div>' | |
| ) | |
| def _sub_hdr(icon: str, clr: str, title: str, desc: str) -> str: | |
| """서브탭 섹션 헤더 HTML 생성 (clr = 'r,g,b' 형식)""" | |
| return ( | |
| f'<div style="background:linear-gradient(135deg,#ffffff,#f0f4ff);' | |
| f'border:1px solid rgba({clr},.18);border-radius:16px;padding:20px 24px;' | |
| f'margin-bottom:16px;position:relative;overflow:hidden;' | |
| f'box-shadow:0 2px 12px rgba(99,102,241,.06);">' | |
| f'<div style="position:absolute;top:-20px;right:-20px;width:120px;height:120px;' | |
| f'border-radius:50%;background:radial-gradient(circle,rgba({clr},.08),transparent);' | |
| f'pointer-events:none;"></div>' | |
| f'<div style="display:flex;align-items:center;gap:14px;">' | |
| f'<div style="width:44px;height:44px;border-radius:12px;' | |
| f'background:linear-gradient(135deg,rgba({clr},.12),rgba(124,58,237,.06));' | |
| f'display:flex;align-items:center;justify-content:center;font-size:22px;' | |
| f'border:1px solid rgba({clr},.2);">{icon}</div>' | |
| f'<div><div style="color:#1e293b;font-weight:800;font-size:17px;">{title}</div>' | |
| f'<div style="color:#475569;font-size:13px;">{desc}</div>' | |
| f'</div></div></div>' | |
| ) | |
| # ─── 텍스트 유틸리티 함수 (표절·유사도) ────────────────────────────────────── | |
| def _clean_text(t): return re.sub(r'\s+', ' ', unicodedata.normalize('NFC', ''.join(c for c in t if c not in ALL_ZW and c not in ALL_MICRO))).strip() | |
| def _split_sentences(t): return [s.strip() for s in re.split(r'(?<=[.!?。])\s*', t) if s.strip() and len(s.strip()) > 3] | |
| def _ngrams(text, n): words = text.split(); return set(tuple(words[i:i+n]) for i in range(len(words)-n+1)) if len(words) >= n else set() | |
| def _jaccard(sa, sb): return len(sa & sb) / max(len(sa | sb), 1) if sa or sb else 0.0 | |
| def _sentence_similarity(s1, s2): return SequenceMatcher(None, s1, s2).ratio() * 0.6 + _jaccard(_ngrams(s1, 2), _ngrams(s2, 2)) * 0.4 | |
| # ─── 이미지·영상 임계값 ──────────────────────────────────────────────────────── | |
| _IMG_THRESHOLDS = [(90,"🔴 확실한 도용/복제","#b91c1c","직접 복사 또는 최소 편집"),(70,"🟠 높은 유사도 — 도용 의심","#c2410c","편집된 복사본 가능성"),(50,"🟡 보통 유사도 — 주의 필요","#b45309","AI 재생성 또는 영감 기반"),(30,"🟢 낮은 유사도 — 참고 수준","#0d9488","우연한 유사 가능성"),(0,"⚪ 관련 없음","#64748b","서로 다른 이미지")] | |
| _VID_THRESHOLDS = [(85,"🔴 확실한 도용/복제","#b91c1c","동일 영상 또는 최소 편집"),(65,"🟠 높은 유사도 — 도용 의심","#c2410c","편집·크롭·속도 변경 복사본"),(45,"🟡 보통 유사도 — 주의","#b45309","부분 복사 또는 유사 촬영"),(25,"🟢 낮은 유사도","#0d9488","참고 수준"),(0,"⚪ 관련 없음","#64748b","서로 다른 영상")] | |
| # ─── HTML 렌더 유틸리티 (이미지·영상) ───────────────────────────────────────── | |
| def _sim_verdict(total, thresholds): | |
| for thresh,v,c,d in thresholds: | |
| if total >= thresh: return v,c,d | |
| return thresholds[-1][1],thresholds[-1][2],thresholds[-1][3] | |
| def _metric_grid(metrics): | |
| return '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">' + ''.join(f'<div style="{_CARD}"><div style="font-size:24px;font-weight:900;color:{c};">{v}%</div><div style="font-size:11px;color:#64748b;margin-top:4px;">{n}</div></div>' for n,v,c in metrics) + '</div>' | |
| def _sim_html(total,verdict,vc,vi,metrics,extra=""): | |
| return f'''<div style="background:linear-gradient(135deg,#ffffff,#f0f4ff);border-radius:20px;padding:28px;border:1px solid {vc}33;"><div style="text-align:center;margin-bottom:24px;"><div style="font-size:56px;font-weight:900;color:{vc};">{total}%</div><div style="font-size:18px;font-weight:800;color:{vc};margin:4px 0;">{verdict}</div><div style="font-size:13px;color:#64748b;">{vi}</div></div>{_metric_grid(metrics)}{extra}</div>''' | |
| # ─── 워터마크 시각화 렌더 ───────────────────────────────────────────────────── | |
| def render_wm_html(wm_text, max_chars=500): | |
| zw_cnt = sum(1 for c in wm_text if c in ALL_ZW) | |
| vs_cnt = sum(1 for c in wm_text if c in VS_CHARS) | |
| cgj_cnt = sum(1 for c in wm_text if c == CGJ) | |
| vis_cnt = sum(1 for c in wm_text if c not in ALL_ZW and c not in ALL_MICRO) | |
| density = round((zw_cnt + vs_cnt + cgj_cnt) / max(vis_cnt, 1) * 100, 1) | |
| stats = f'''<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:14px;"> | |
| <div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #00b89433;"> | |
| <div style="font-size:20px;font-weight:900;color:#00b894;">{zw_cnt}</div> | |
| <div style="font-size:10px;color:#64748b;margin-top:2px;">L1 ZW 마커</div></div> | |
| <div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #7c3aed33;"> | |
| <div style="font-size:20px;font-weight:900;color:#7c3aed;">{vs_cnt}</div> | |
| <div style="font-size:10px;color:#64748b;margin-top:2px;">L4 VS 마커</div></div> | |
| <div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #dd6b2033;"> | |
| <div style="font-size:20px;font-weight:900;color:#f97316;">{cgj_cnt}</div> | |
| <div style="font-size:10px;color:#64748b;margin-top:2px;">L4 CGJ 마커</div></div> | |
| <div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #3182ce33;"> | |
| <div style="font-size:20px;font-weight:900;color:#3182ce;">{density}%</div> | |
| <div style="font-size:10px;color:#64748b;margin-top:2px;">워터마크 밀도</div></div> | |
| </div>''' | |
| legend = '''<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:6px;font-size:10px;"> | |
| <span style="background:#fee2e2;color:#b91c1c;padding:2px 8px;border-radius:10px;border:1px solid #fca5a5;">🔴 ZWSP (L1=0)</span> | |
| <span style="background:#d1fae5;color:#065f46;padding:2px 8px;border-radius:10px;border:1px solid #6ee7b7;">🟢 ZWNJ (L1=1)</span> | |
| <span style="background:#fef9c3;color:#854d0e;padding:2px 8px;border-radius:10px;border:1px solid #fde047;">🟡 BOM (L1 구분자)</span> | |
| <span style="background:rgba(109,40,217,.1);color:#6d28d9;padding:2px 8px;border-radius:10px;border:1px solid rgba(109,40,217,.25);">🟣 VS (L4 구두점)</span> | |
| <span style="background:#fff7ed;color:#c2410c;padding:2px 8px;border-radius:10px;border:1px solid #fed7aa;">🟠 CGJ (L4 한글)</span> | |
| </div>''' | |
| parts = [f'<div style="{BOX}"><div style="font-weight:700;font-size:13px;color:#00b894;margin-bottom:12px;">📊 워터마크 삽입 현황</div>', stats, legend, | |
| '<div style="background:#f8fafc;border-radius:10px;padding:14px;"><div style="background:linear-gradient(90deg,rgba(0,184,148,.06),rgba(0,184,148,.02));border-radius:8px 8px 0 0;padding:8px 12px;margin:-14px -14px 12px;border-bottom:1px solid #e2e8f0;font-size:11px;color:#94a3b8;">워터마크 삽입 텍스트 미리보기 (숨겨진 마커 포함)</div>', | |
| f'<div style="line-height:2.4;font-size:14px;">'] | |
| count = 0 | |
| for ch in wm_text: | |
| if count >= max_chars and ch not in ALL_ZW and ch not in ALL_MICRO: | |
| parts.append('<span style="color:#64748b;"> ...</span>'); break | |
| if ch in ALL_ZW: | |
| s,c = ZW_NAMES.get(ch,('?','#475569')) | |
| # 배경: 해당 색의 10% 투명도, 텍스트: 진한 원색 | |
| parts.append(f'<span style="background:{c}18;color:{c};font-size:8px;padding:0 4px;border-radius:3px;vertical-align:middle;border:1px solid {c}44;font-weight:700;">{s}</span>') | |
| elif ch in VS_CHARS: | |
| parts.append(f'<span style="background:#6d28d9;color:#ffffff;font-size:7px;padding:0 3px;border-radius:3px;vertical-align:middle;">VS</span>') | |
| elif ch == CGJ: | |
| parts.append(f'<span style="background:#f97316;color:#000;font-size:7px;padding:0 3px;border-radius:3px;vertical-align:middle;">CGJ</span>') | |
| else: | |
| parts.append(html_lib.escape(ch)); count += 1 | |
| parts.append('</div></div></div>') | |
| return ''.join(parts) | |
| def render_morph_html(text, boundaries): | |
| parts = [f'<div style="{BOX}"><div style="font-weight:700;font-size:13px;color:#00b894;margin-bottom:12px;">🧬 형태소 경계 & 삽입 위치</div><div style="line-height:2.8;font-size:14px;">'] | |
| prev = 0 | |
| for b in sorted(boundaries, key=lambda x: x['pos']): | |
| pos = b['pos']; seg = text[prev:pos] | |
| if seg: parts.append(html_lib.escape(seg)) | |
| color = '#00b894' if b.get('type') == 'morpheme' else '#60a5fa' | |
| desc_escaped = html_lib.escape(b.get('desc', '')) | |
| parts.append(f'<span style="border-bottom:2px solid {color};margin:0 1px;position:relative;" title="{desc_escaped}"><span style="position:absolute;top:-16px;left:50%;transform:translateX(-50%);font-size:7px;color:{color};white-space:nowrap;">▼</span></span>') | |
| prev = pos | |
| parts.append(html_lib.escape(text[prev:])); parts.append('</div></div>') | |
| return ''.join(parts) | |
| def render_diff_html(orig, mod): | |
| orig_words = orig.split(); mod_words = mod.split(); sm = SequenceMatcher(None, orig_words, mod_words) | |
| parts = [f'<div style="{BOX}"><div style="font-weight:700;font-size:13px;color:#00b894;margin-bottom:12px;">🔄 원본 vs 워터마크 변형 비교</div><div style="line-height:2.2;font-size:14px;">'] | |
| for tag, i1, i2, j1, j2 in sm.get_opcodes(): | |
| if tag == 'equal': | |
| parts.append(html_lib.escape(' '.join(orig_words[i1:i2])) + ' ') | |
| elif tag == 'replace': | |
| parts.append(f'<span style="background:rgba(229,62,62,.1);color:#e53e3e;text-decoration:line-through;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(orig_words[i1:i2]))}</span> ') | |
| parts.append(f'<span style="background:rgba(0,184,148,.1);color:#00b894;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(mod_words[j1:j2]))}</span> ') | |
| elif tag == 'delete': | |
| parts.append(f'<span style="background:rgba(229,62,62,.1);color:#e53e3e;text-decoration:line-through;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(orig_words[i1:i2]))}</span> ') | |
| elif tag == 'insert': | |
| parts.append(f'<span style="background:rgba(0,184,148,.1);color:#00b894;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(mod_words[j1:j2]))}</span> ') | |
| parts.append('</div></div>') | |
| return ''.join(parts) | |
| # ─── 34종 공격 대시보드 렌더 ────────────────────────────────────────────────── | |
| def render_30atk_dashboard(group_results): | |
| # 위험도 뱃지: (연한 배경, 진한 텍스트) — 흰 배경에서 확실히 구분 | |
| _ATK_BADGE = { | |
| "Low": ("rgba(13,148,136,.12)", "#0d9488"), # teal | |
| "Med": ("rgba(180,83,9,.12)", "#b45309"), # amber | |
| "High": ("rgba(194,65,12,.12)", "#c2410c"), # orange | |
| "Max": ("rgba(185,28,28,.12)", "#b91c1c"), # red | |
| "Tier1": ("rgba(109,40,217,.12)", "#6d28d9"), # violet | |
| } | |
| _ATK_LABELS = {"Low":"낮음","Med":"중간","High":"높음","Max":"최대","Tier1":"Tier1"} | |
| all_rows = ""; total_attacks = total_detected = total_strong = 0 | |
| for group_name, attacks in group_results: | |
| group_det = sum(1 for _,_,_,l1,l2,l4,tr,_,lbl,_,_ in attacks if l1 or l2 or l4 or tr >= 25) | |
| det_color = "#166534" if group_det == len(attacks) else "#1d4ed8" if group_det > 0 else "#6b7280" | |
| all_rows += ( | |
| f'<tr style="background:#f0fdf4;">' + | |
| f'<td colspan="9" style="padding:10px 12px;font-weight:800;font-size:12px;' + | |
| f'border-bottom:2px solid #e2e8f0;border-top:1px solid #d1fae5;color:#1e293b;">' + | |
| f'{group_name} ' + | |
| f'<span style="margin-left:6px;padding:2px 8px;border-radius:10px;' + | |
| f'background:rgba(0,184,148,.1);color:{det_color};font-size:10px;font-weight:700;">' + | |
| f'탐지 {group_det}/{len(attacks)}</span></td></tr>' | |
| ) | |
| for name, desc, risk, l1, l2, l4, trace, trace_reasons, label, vbg, vlc in attacks: | |
| total_attacks += 1; is_det = l1 or l2 or l4 or trace >= 25 | |
| if is_det: total_detected += 1 | |
| if "강력" in label: total_strong += 1 | |
| rbg, rtc = _ATK_BADGE.get(risk, ("rgba(100,116,139,.1)", "#475569")) | |
| rl = _ATK_LABELS.get(risk, risk) | |
| # 흔적 바 — 충분히 진한 색 사용 | |
| tr_clr = "#6d28d9" if trace>=50 else "#c2410c" if trace>=25 else "#94a3b8" | |
| tr_bar = ( | |
| f'<div style="display:flex;align-items:center;gap:5px;">' + | |
| f'<div style="width:40px;height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden;">' + | |
| f'<div style="width:{min(trace,100)}%;height:100%;background:{tr_clr};border-radius:3px;"></div>' + | |
| f'</div>' + | |
| f'<span style="font-size:10px;font-weight:700;color:{tr_clr};">{trace}</span>' + | |
| f'</div>' | |
| ) | |
| tr_ev = ('; '.join(trace_reasons[:2])) if trace_reasons else '' | |
| row_bg = "#f0fdf4" if is_det else "#ffffff" | |
| x_span = '<span style="color:#cbd5e1;font-size:13px;">✕</span>' | |
| all_rows += ( | |
| f'<tr style="border-bottom:1px solid #e2e8f0;background:{row_bg};">' + | |
| f'<td style="padding:7px 10px;color:#1e293b;font-size:12px;font-weight:700;">{html_lib.escape(name)}</td>' + | |
| f'<td style="padding:7px 10px;color:#475569;font-size:11px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{html_lib.escape(desc)}</td>' + | |
| f'<td style="padding:7px 10px;text-align:center;">' + | |
| f'<span style="background:{rbg};color:{rtc};padding:2px 9px;border-radius:8px;font-size:10px;font-weight:700;border:1px solid {rtc}33;">{rl}</span>' + | |
| f'</td>' + | |
| f'<td style="padding:7px 10px;text-align:center;font-size:15px;">{"✅" if l1 else x_span}</td>' + | |
| f'<td style="padding:7px 10px;text-align:center;font-size:15px;">{"✅" if l2 else x_span}</td>' + | |
| f'<td style="padding:7px 10px;text-align:center;font-size:15px;">{"✅" if l4 else x_span}</td>' + | |
| f'<td style="padding:7px 10px;">{tr_bar}</td>' + | |
| f'<td style="padding:7px 10px;text-align:center;">' + | |
| f'<span style="background:{vbg};color:{vlc};padding:3px 9px;border-radius:10px;font-size:10px;font-weight:800;white-space:nowrap;border:1px solid {vlc}33;">{label}</span>' + | |
| f'</td>' + | |
| f'<td style="padding:7px 10px;color:#64748b;font-size:10px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{html_lib.escape(tr_ev)}</td>' + | |
| f'</tr>' | |
| ) | |
| detect_pct = total_detected / max(total_attacks, 1) * 100 | |
| cx,cy,r,sw = 50,50,40,9; circ = 2*3.14159*r | |
| det_dash = circ*detect_pct/100; str_dash = circ*total_strong/max(total_attacks,1)*100/100 | |
| pct_fill = "#166534" if detect_pct>=80 else "#854d0e" if detect_pct>=50 else "#b91c1c" | |
| donut_svg = ( | |
| f'<svg width="100" height="100" viewBox="0 0 100 100">' + | |
| f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#e2e8f0" stroke-width="{sw}"/>' + | |
| f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#93c5fd" stroke-width="{sw}"' + | |
| f' stroke-dasharray="{det_dash} {circ}" stroke-dashoffset="0"' + | |
| f' transform="rotate(-90 {cx} {cy})" stroke-linecap="round"/>' + | |
| f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#166534" stroke-width="{sw}"' + | |
| f' stroke-dasharray="{str_dash} {circ}" stroke-dashoffset="0"' + | |
| f' transform="rotate(-90 {cx} {cy})" stroke-linecap="round"/>' + | |
| f'<text x="{cx}" y="{cy-3}" text-anchor="middle" fill="{pct_fill}" font-size="17" font-weight="900">{detect_pct:.0f}%</text>' + | |
| f'<text x="{cx}" y="{cy+10}" text-anchor="middle" fill="#64748b" font-size="7">커버리지</text>' + | |
| f'</svg>' | |
| ) | |
| _ATK_GROUPS_KO = ["유니코드 정규화","제로폭 제거","조합 마크","공백·구두점","토큰화 교란","새니타이저","의미 보존 재작성"] | |
| group_bars = "" | |
| for gi, (group_name, attacks) in enumerate(group_results): | |
| gdet = sum(1 for _,_,_,l1,l2,l4,tr,_,_,_,_ in attacks if l1 or l2 or l4 or tr >= 25) | |
| gh = max(gdet / max(len(attacks),1) * 60, 4) | |
| gc = "#166534" if gdet==len(attacks) else "#1d4ed8" if gdet>0 else "#cbd5e1" | |
| short = _ATK_GROUPS_KO[gi] if gi < len(_ATK_GROUPS_KO) else f"그룹{gi+1}" | |
| group_bars += ( | |
| f'<div style="display:flex;flex-direction:column;align-items:center;gap:4px;flex:1;">' + | |
| f'<div style="width:100%;height:{gh}px;background:{gc};border-radius:4px 4px 0 0;min-height:4px;"></div>' + | |
| f'<div style="font-size:8px;color:#475569;text-align:center;writing-mode:vertical-rl;height:55px;overflow:hidden;">{short}</div>' + | |
| f'</div>' | |
| ) | |
| summary_pct_bg = "#dcfce7" if detect_pct>=80 else "#fef9c3" if detect_pct>=50 else "#fee2e2" | |
| summary_pct_tc = "#166534" if detect_pct>=80 else "#854d0e" if detect_pct>=50 else "#991b1b" | |
| cards = ( | |
| f'<div style="display:flex;gap:16px;margin-bottom:20px;align-items:stretch;flex-wrap:wrap;">' + | |
| f'<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;' + | |
| f'background:linear-gradient(135deg,#f0fdf4,#eff6ff);' + | |
| f'border:1px solid #bbf7d0;border-radius:14px;min-width:280px;">' + | |
| f'{donut_svg}' + | |
| f'<div>' + | |
| f'<div style="display:flex;gap:16px;margin-bottom:10px;">' + | |
| f'<div><div style="color:#64748b;font-size:10px;font-weight:600;">공격 수</div>' + | |
| f'<div style="color:#1e293b;font-size:22px;font-weight:900;">{total_attacks}</div></div>' + | |
| f'<div><div style="color:#64748b;font-size:10px;font-weight:600;">탐지+흔적</div>' + | |
| f'<div style="color:#1d4ed8;font-size:22px;font-weight:900;">{total_detected}</div></div>' + | |
| f'<div><div style="color:#64748b;font-size:10px;font-weight:600;">강력 탐지</div>' + | |
| f'<div style="color:#166534;font-size:22px;font-weight:900;">{total_strong}</div></div>' + | |
| f'</div>' + | |
| f'<div style="display:flex;gap:6px;">' + | |
| f'<span style="padding:3px 8px;border-radius:6px;background:#dcfce7;color:#166534;font-size:9px;font-weight:700;border:1px solid #bbf7d0;">● 강력 탐지</span>' + | |
| f'<span style="padding:3px 8px;border-radius:6px;background:#dbeafe;color:#1d4ed8;font-size:9px;font-weight:700;border:1px solid #bfdbfe;">● 탐지+흔적</span>' + | |
| f'</div></div></div>' + | |
| f'<div style="flex:1;display:flex;gap:4px;padding:12px 16px;background:#ffffff;' + | |
| f'border:1px solid #e2e8f0;border-radius:14px;align-items:flex-end;min-width:300px;">{group_bars}</div>' + | |
| f'</div>' | |
| ) | |
| thead = ( | |
| f'<thead><tr style="background:#f8fafc;border-bottom:2px solid #e2e8f0;">' + | |
| f'<th style="padding:9px 10px;text-align:left;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #00b894;">공격명</th>' + | |
| f'<th style="padding:9px 10px;text-align:left;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #00b894;">설명</th>' + | |
| f'<th style="padding:9px 10px;text-align:center;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #e2e8f0;">위험도</th>' + | |
| f'<th style="padding:9px 10px;text-align:center;color:#0d9488;font-size:11px;font-weight:700;border-bottom:2px solid #0d9488;">L1<br><span style="font-size:9px;font-weight:400;color:#64748b;">ZW</span></th>' + | |
| f'<th style="padding:9px 10px;text-align:center;color:#1d4ed8;font-size:11px;font-weight:700;border-bottom:2px solid #1d4ed8;">L2<br><span style="font-size:9px;font-weight:400;color:#64748b;">문체</span></th>' + | |
| f'<th style="padding:9px 10px;text-align:center;color:#6d28d9;font-size:11px;font-weight:700;border-bottom:2px solid #6d28d9;">L4<br><span style="font-size:9px;font-weight:400;color:#64748b;">Mn</span></th>' + | |
| f'<th style="padding:9px 10px;text-align:center;color:#6d28d9;font-size:11px;font-weight:700;border-bottom:2px solid #6d28d9;">흔적</th>' + | |
| f'<th style="padding:9px 10px;text-align:center;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #e2e8f0;">판정</th>' + | |
| f'<th style="padding:9px 10px;text-align:left;color:#64748b;font-size:10px;font-weight:600;border-bottom:2px solid #e2e8f0;">흔적 증거</th>' + | |
| f'</tr></thead>' | |
| ) | |
| return ( | |
| f'<div style="{BOX}">' + | |
| f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">' + | |
| f'<div>' + | |
| f'<div style="color:#1e293b;font-weight:800;font-size:18px;">🔥 34종 공격 대시보드</div>' + | |
| f'<div style="color:#475569;font-size:12px;margin-top:2px;">' + | |
| f'이중축: <span style="color:#166534;font-weight:700;">신호(Signal)</span> + ' + | |
| f'<span style="color:#5b21b6;font-weight:700;">흔적(Trace)</span> = 워터마크 파괴 후에도 저작권 증거 유지' + | |
| f'</div></div>' + | |
| f'<div style="background:{summary_pct_bg};color:{summary_pct_tc};padding:8px 18px;' + | |
| f'border-radius:14px;font-weight:800;font-size:14px;' + | |
| f'border:1px solid {summary_pct_tc}33;">{detect_pct:.0f}% 커버리지</div>' + | |
| f'</div>' + | |
| f'{cards}' + | |
| f'<div style="overflow-x:auto;">' + | |
| f'<table style="width:100%;border-collapse:collapse;min-width:700px;">' + | |
| f'{thead}<tbody>{all_rows}</tbody></table></div></div>' | |
| ) | |
| # ─── LLM 대시보드 렌더 ────────────────────────────────────────────────────── | |
| def render_llm_dashboard(results): | |
| rows = "" | |
| for mid, display, status, l1, l2, l4, preview in results: | |
| l1i = "✅" if l1 else "✕"; l2i = "✅" if l2 else "✕"; l4i = "✅" if l4 else "✕" | |
| strong = (l1 and l2) or (l1 and l4) or (l2 and l4) | |
| any_d = l1 or l2 or l4; score = sum([l1, l2, l4]) | |
| if strong: bg, lc, label = "#dcfce7", "#166534", "강력 탐지" | |
| elif any_d: bg, lc, label = "#fef9c3", "#854d0e", "부분 탐지" | |
| else: bg, lc, label = "#fee2e2", "#991b1b", "미탐지" | |
| bar_w = score / 3 * 100 | |
| bar_c = "#166534" if score >= 2 else "#b45309" if score >= 1 else "#e2e8f0" | |
| mini_bar = ( | |
| f'<div style="display:flex;align-items:center;gap:6px;">' + | |
| f'<div style="width:48px;height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden;">' + | |
| f'<div style="width:{bar_w}%;height:100%;background:{bar_c};border-radius:3px;"></div></div>' + | |
| f'<span style="font-size:10px;color:{bar_c};font-weight:700;">{score}/3</span></div>' | |
| ) | |
| rows += ( | |
| f'<tr style="border-bottom:1px solid #e2e8f0;">' + | |
| f'<td style="padding:10px;color:#1e293b;font-weight:700;font-size:13px;">{html_lib.escape(display)}</td>' + | |
| f'<td style="padding:10px;color:#64748b;font-size:11px;">{html_lib.escape(mid[:30])}</td>' + | |
| f'<td style="padding:10px;text-align:center;font-size:16px;">{l1i}</td>' + | |
| f'<td style="padding:10px;text-align:center;font-size:16px;">{l2i}</td>' + | |
| f'<td style="padding:10px;text-align:center;font-size:16px;">{l4i}</td>' + | |
| f'<td style="padding:10px;">{mini_bar}</td>' + | |
| f'<td style="padding:10px;text-align:center;">' + | |
| f'<span style="background:{bg};color:{lc};padding:4px 12px;border-radius:14px;font-size:11px;font-weight:800;border:1px solid {lc}33;">{label}</span>' + | |
| f'</td></tr>' | |
| ) | |
| total = len(results) | |
| det = sum(1 for r in results if r[3] or r[4] or r[5]) | |
| strong_n = sum(1 for r in results if (r[3] and r[4]) or (r[3] and r[5]) or (r[4] and r[5])) | |
| det_pct = det / max(total, 1) * 100 | |
| r2, cx, cy, sw = 36, 45, 45, 7; circ = 2 * 3.14159 * r2; det_d = circ * det_pct / 100 | |
| donut = ( | |
| f'<svg width="100" height="100" viewBox="0 0 90 90">' + | |
| f'<circle cx="{cx}" cy="{cy}" r="{r2}" fill="none" stroke="#e2e8f0" stroke-width="{sw}"/>' + | |
| f'<circle cx="{cx}" cy="{cy}" r="{r2}" fill="none" stroke="#00b894" stroke-width="{sw}" ' + | |
| f'stroke-dasharray="{det_d} {circ}" stroke-dashoffset="0" transform="rotate(-90 {cx} {cy})" stroke-linecap="round"/>' + | |
| f'<text x="{cx}" y="{cy-2}" text-anchor="middle" fill="#166534" font-size="16" font-weight="900">{det}</text>' + | |
| f'<text x="{cx}" y="{cy+10}" text-anchor="middle" fill="#64748b" font-size="7">/{total}</text>' + | |
| f'</svg>' | |
| ) | |
| summary = ( | |
| f'<div style="display:flex;gap:16px;margin-bottom:20px;align-items:center;flex-wrap:wrap;">' + | |
| f'<div style="display:flex;align-items:center;gap:12px;padding:14px 20px;' + | |
| f'background:#ffffff;border:1px solid rgba(0,184,148,.2);border-radius:14px;">' + | |
| f'{donut}' + | |
| f'<div><div style="color:#1e293b;font-size:14px;font-weight:800;">LLM 탐지 결과</div>' + | |
| f'<div style="color:#475569;font-size:12px;margin-top:2px;">{total}개 모델 테스트 완료</div></div></div>' + | |
| f'<div style="display:flex;gap:10px;flex:1;min-width:200px;">' + | |
| f'<div style="flex:1;text-align:center;padding:14px;background:#dcfce7;border-radius:12px;border:1px solid #bbf7d0;">' + | |
| f'<div style="color:#166534;font-size:26px;font-weight:900;">{strong_n}</div>' + | |
| f'<div style="color:#166534;font-size:11px;">강력 탐지</div></div>' + | |
| f'<div style="flex:1;text-align:center;padding:14px;background:#fef9c3;border-radius:12px;border:1px solid #fde68a;">' + | |
| f'<div style="color:#854d0e;font-size:26px;font-weight:900;">{det - strong_n}</div>' + | |
| f'<div style="color:#854d0e;font-size:11px;">부분 탐지</div></div>' + | |
| f'<div style="flex:1;text-align:center;padding:14px;background:#fee2e2;border-radius:12px;border:1px solid #fecaca;">' + | |
| f'<div style="color:#991b1b;font-size:26px;font-weight:900;">{total - det}</div>' + | |
| f'<div style="color:#991b1b;font-size:11px;">미탐지</div></div>' + | |
| f'</div></div>' | |
| ) | |
| return ( | |
| f'<div style="{BOX}">' + | |
| f'<div style="color:#1e293b;font-weight:800;font-size:18px;margin-bottom:16px;">🤖 LLM 배치 탐지 결과</div>' + | |
| f'{summary}' + | |
| f'<div style="overflow-x:auto;">' + | |
| f'<table style="width:100%;border-collapse:collapse;">' + | |
| f'<thead><tr style="background:#f8fafc;border-bottom:2px solid #00b894;">' + | |
| f'<th style="padding:10px;text-align:left;color:#1e293b;font-size:12px;font-weight:700;">모델명</th>' + | |
| f'<th style="padding:10px;text-align:left;color:#64748b;font-size:11px;font-weight:600;">모델 ID</th>' + | |
| f'<th style="padding:10px;text-align:center;color:#0d9488;font-size:11px;font-weight:700;">L1</th>' + | |
| f'<th style="padding:10px;text-align:center;color:#1d4ed8;font-size:11px;font-weight:700;">L2</th>' + | |
| f'<th style="padding:10px;text-align:center;color:#6d28d9;font-size:11px;font-weight:700;">L4</th>' + | |
| f'<th style="padding:10px;text-align:center;color:#475569;font-size:12px;font-weight:600;">계층</th>' + | |
| f'<th style="padding:10px;text-align:center;color:#1e293b;font-size:11px;font-weight:700;">판정</th>' + | |
| f'</tr></thead><tbody>{rows}</tbody></table></div></div>' | |
| ) | |
| # ─── 파이프라인 렌더 ───────────────────────────────────────────────────────── | |
| def render_pipeline_html(stages): | |
| cards = ""; first_zw = max(stages[0][3], 1) | |
| for i, (name, desc, zw0, zw_a, l2, l4, vis) in enumerate(stages): | |
| zw_pct = zw_a / first_zw * 100; alive = sum([zw_a > 0, l2, l4]) | |
| if alive == 3: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#166534", "3/3 생존", "🟢" | |
| elif alive == 2: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#fef9c3,#fef08a)", "#b45309", "2/3 생존", "🟡" | |
| elif alive == 1: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#ffedd5,#fed7aa)", "#c2410c", "1/3 생존", "🟠" | |
| else: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#fee2e2,#fecaca)", "#b91c1c", "전체 소실", "🔴" | |
| zw_bar = ( | |
| f'<div style="background:#e2e8f0;border-radius:4px;height:5px;overflow:hidden;margin:6px 0;">' + | |
| f'<div style="width:{min(zw_pct,100):.0f}%;height:100%;background:#0d9488;border-radius:4px;"></div></div>' | |
| ) | |
| note = "" | |
| if i > 0 and zw_a == 0 and stages[i-1][3] > 0: | |
| note = f'<div style="font-size:10px;color:#c2410c;margin-top:4px;">⚠ 이 단계에서 L1(ZW) 소실 — L2 문체+L4 Mn 방어 유지</div>' | |
| cards += ( | |
| f'<div style="background:{health_bg};border:1px solid {health_border}33;' + | |
| f'border-radius:14px;padding:14px;position:relative;">' + | |
| f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">' + | |
| f'<div style="color:#1e293b;font-weight:700;font-size:12px;">{i+1}. {name}</div>' + | |
| f'<span style="background:rgba(0,0,0,.06);color:{health_border};padding:2px 7px;border-radius:8px;font-size:10px;font-weight:700;">{health_icon} {health_label}</span>' + | |
| f'</div>' + | |
| f'<div style="color:#64748b;font-size:10px;margin-bottom:4px;">{desc}</div>' + | |
| f'{zw_bar}' + | |
| f'<div style="display:flex;justify-content:space-between;font-size:10px;color:#64748b;">' + | |
| f'<span>ZW 잔존 {zw_a}/{stages[0][3]}</span>' + | |
| f'<span>L2 {"✅" if l2 else "✕"} L4 {"✅" if l4 else "✕"}</span>' + | |
| f'</div>{note}</div>' | |
| ) | |
| total_stages = len(stages); final_alive = sum([stages[-1][3] > 0, stages[-1][4], stages[-1][5]]) | |
| summary = ( | |
| f'<div style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;">' + | |
| f'<div style="padding:10px 16px;background:#ffffff;border-radius:10px;border:1px solid #e2e8f0;text-align:center;">' + | |
| f'<div style="color:#64748b;font-size:10px;">단계</div>' + | |
| f'<div style="color:#1e293b;font-size:20px;font-weight:900;">{total_stages}</div></div>' + | |
| f'<div style="padding:10px 16px;background:#ffffff;border-radius:10px;border:1px solid #e2e8f0;text-align:center;">' + | |
| f'<div style="color:#64748b;font-size:10px;">최종 생존</div>' + | |
| f'<div style="color:{"#166534" if final_alive>=2 else "#b91c1c"};font-size:20px;font-weight:900;">{final_alive}/3</div></div>' + | |
| f'<div style="padding:10px 16px;background:#ffffff;border-radius:10px;border:1px solid #e2e8f0;text-align:center;">' + | |
| f'<div style="color:#64748b;font-size:10px;">L1 ZW 잔존</div>' + | |
| f'<div style="color:#0d9488;font-size:20px;font-weight:900;">{stages[-1][3]}</div></div>' + | |
| f'</div>' | |
| ) | |
| return ( | |
| f'<div style="{BOX}">' + | |
| f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">' + | |
| f'<div>' + | |
| f'<div style="color:#1e293b;font-weight:800;font-size:16px;">⚙️ AI 파이프라인 — 계층 생존 추적</div>' + | |
| f'<div style="color:#475569;font-size:12px;margin-top:2px;">10단계 전처리 → 3계층 생존 추적</div>' + | |
| f'</div></div>' + | |
| f'{summary}' + | |
| f'<div style="color:#475569;font-size:10px;margin-bottom:12px;padding:8px 12px;background:#f0f4ff;border-radius:8px;">' + | |
| f'💡 L1(ZW)이 제거되어도 L2(문체)와 L4(VS/CGJ)가 생존하면 워터마크 탐지 가능 — ' + | |
| f'이것이 <span style="color:#166534;font-weight:700;">다계층 방어</span>의 핵심입니다.</div>' + | |
| f'<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;">{cards}</div></div>' | |
| ) | |
| # ─── 표절 보고서 렌더 ───────────────────────────────────────────────────────── | |
| def _render_plagiarism_html(score, grade, gc, gbg, ptype, picon, ng_scores, orig_sents, susp_sents, matrix, coverage, order_ratio, sent_avg, ng_avg): | |
| r_g, cx_g, cy_g = 42, 55, 55; circ_g = 3.14159 * r_g; fill_g = circ_g * score / 100 | |
| gauge_svg = f'''<svg width="120" height="75" viewBox="0 0 110 70"><path d="M 10 58 A {r_g} {r_g} 0 0 1 100 58" fill="none" stroke="#e2e8f0" stroke-width="8" stroke-linecap="round"/><path d="M 10 58 A {r_g} {r_g} 0 0 1 100 58" fill="none" stroke="{gc}" stroke-width="8" stroke-linecap="round"stroke-dasharray="{fill_g} {circ_g}" opacity=".85"/><text x="55" y="46" text-anchor="middle" fill="{gc}" font-size="22" font-weight="900">{score}</text><text x="55" y="60" text-anchor="middle" fill="#64748b" font-size="8">/100</text></svg>''' | |
| cards = f"""<div style="display:flex;gap:14px;margin-bottom:20px;flex-wrap:wrap;align-items:stretch;"><div style="display:flex;align-items:center;gap:12px;padding:16px 20px;background:#ffffff;border:1px solid {gc}22;border-radius:14px;min-width:240px;">{gauge_svg}<div><div style="background:{gbg};color:{gc};padding:6px 16px;border-radius:12px;font-weight:900;font-size:14px;text-align:center;margin-bottom:6px;">{picon} {grade}</div><div style="color:#64748b;font-size:11px;text-align:center;">{ptype}</div></div></div><div style="flex:1;display:grid;grid-template-columns:repeat(3,1fr);gap:8px;min-width:250px;"><div style="text-align:center;padding:14px;background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;"><div style="color:#d69e2e;font-size:24px;font-weight:900;">{coverage:.0%}</div><div style="color:#64748b;font-size:11px;">문장 커버리지</div></div><div style="text-align:center;padding:14px;background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;"><div style="color:#7c3aed;font-size:24px;font-weight:900;">{sent_avg:.0%}</div><div style="color:#64748b;font-size:11px;">문장 유사도</div></div><div style="text-align:center;padding:14px;background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;"><div style="color:#3182ce;font-size:24px;font-weight:900;">{ng_avg:.0%}</div><div style="color:#64748b;font-size:11px;">N-gram 중복도</div></div></div></div>""" | |
| ng_bars = "" | |
| for n, s in ng_scores.items(): | |
| bc = "#0d9488" if s>=0.5 else "#b45309" if s>=0.25 else "#b91c1c" if s>=0.1 else "#cbd5e1" | |
| ng_bars += f"""<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;"><span style="color:#64748b;font-size:11px;width:52px;text-align:right;">{n}-gram</span><div style="flex:1;height:10px;background:#e2e8f0;border-radius:5px;overflow:hidden;"><div style="height:100%;width:{min(s*100,100)}%;background:linear-gradient(90deg,{bc}88,{bc});border-radius:5px;"></div></div><span style="color:{bc};font-size:12px;font-weight:700;width:40px;text-align:right;">{s:.0%}</span></div>""" | |
| heatmap_rows = "" | |
| for si, ss in enumerate(susp_sents): | |
| best = next(((o_i, sc, ot) for s_i, o_i, sc, st, ot in matrix if s_i==si), None) | |
| if best and best[1] >= 0.40: | |
| sc_v = best[1]; bg2,lc2,lbl2 = ("rgba(229,62,62,.08)","#c53030","높음") if sc_v>=0.70 else ("rgba(221,107,32,.08)","#c05621","중간") if sc_v>=0.50 else ("rgba(214,158,46,.08)","#b7791f","낮음") | |
| heatmap_rows += f"""<div style="background:{bg2};border-left:3px solid {lc2};padding:8px 12px;margin-bottom:4px;border-radius:0 8px 8px 0;"><div style="display:flex;justify-content:space-between;align-items:center;"><span style="color:#1e293b;font-size:12px;">{html_lib.escape(ss[:80])}</span><span style="background:{lc2}22;color:{lc2};padding:2px 8px;border-radius:10px;font-size:10px;font-weight:700;white-space:nowrap;margin-left:8px;">{lbl2} {sc_v:.0%}</span></div><div style="color:#64748b;font-size:10px;margin-top:4px;">↔ 원본[{best[0]+1}]: {html_lib.escape(best[2][:60])}</div></div>""" | |
| else: heatmap_rows += f"""<div style="padding:6px 12px;margin-bottom:4px;border-left:3px solid #e2e8f0;border-radius:0 8px 8px 0;"><span style="color:#94a3b8;font-size:12px;padding:4px 0;">{html_lib.escape(ss[:80])}</span></div>""" | |
| axes_html = '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:16px;">' + ''.join(f"""<div style="text-align:center;padding:12px;background:#ffffff;border-radius:10px;border:1px solid rgba({color},.12);"><div style="color:{color};font-size:20px;font-weight:800;">{val:.0%}</div><div style="color:#64748b;font-size:10px;margin-top:2px;">{label}</div></div>""" for label, val, color in [("N-gram 중복도",ng_avg,"#1d4ed8"),("문장 유사도",sent_avg,"#6d28d9"),("커버리지",coverage,"#b45309"),("구조 유지",order_ratio,"#0d9488")]) + '</div>' | |
| return f"""<div style="{BOX}"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"><div><div style="color:#1e293b;font-weight:800;font-size:18px;">📋 표절 분석 보고서</div><div style="color:#475569;font-size:12px;margin-top:2px;">원본 {len(orig_sents)}문장 vs 의심 {len(susp_sents)}문장</div></div><div style="background:{gbg};color:{gc};padding:8px 20px;border-radius:16px;font-weight:800;font-size:15px;">{picon} {grade}</div></div>{cards}<div style="color:#1e293b;font-weight:700;font-size:14px;margin:20px 0 10px;">📊 N-gram 분석</div>{ng_bars}{axes_html}<div style="color:#1e293b;font-weight:700;font-size:14px;margin:24px 0 10px;">🔍 문장 히트맵</div><div style="color:#64748b;font-size:11px;margin-bottom:8px;"><span style="color:#e53e3e;">■</span> 높음(70%이상)<span style="color:#dd6b20;margin-left:8px;">■</span> 중간(50%이상)<span style="color:#d69e2e;margin-left:8px;">■</span> 낮음(40%이상)<span style="color:#1e293b;margin-left:8px;">■</span> 미탐지</div>{heatmap_rows}</div>""" | |
| # ─── 복사 버튼 포함 워터마크 삽입 결과 박스 ──────────────────────────────────── | |
| def render_copy_box(wm_text: str, cid: str) -> str: | |
| """워터마크 삽입된 텍스트를 복사 버튼과 함께 표시""" | |
| import json | |
| safe = html_lib.escape(wm_text) | |
| # JSON으로 인코딩해서 JS에서 안전하게 사용 | |
| js_text = json.dumps(wm_text) | |
| char_count = len(wm_text) | |
| zw_n = sum(1 for c in wm_text if c in ALL_ZW) | |
| mn_n = sum(1 for c in wm_text if c in ALL_MICRO) | |
| return f'''<div style="background:linear-gradient(135deg,#f0fdf4,#f4f7ff);border:1px solid #bbf7d0;border-radius:16px;padding:20px;margin:12px 0;box-shadow:0 2px 12px rgba(0,184,148,.08);"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;"> | |
| <div> | |
| <div style="color:#166534;font-weight:800;font-size:15px;">📋 워터마크 삽입 완료 텍스트</div> | |
| <div style="color:#475569;font-size:11px;margin-top:3px;"> | |
| Content ID: <span style="color:#1d4ed8;font-weight:700;">{html_lib.escape(cid)}</span> | |
| · {char_count}자 | |
| · ZW <span style="color:#0d9488;font-weight:700;">{zw_n}</span> | |
| · Mn <span style="color:#6d28d9;font-weight:700;">{mn_n}</span> | |
| </div> | |
| </div> | |
| <button onclick="(function(){{ | |
| var t={js_text}; | |
| if(navigator.clipboard){{ | |
| navigator.clipboard.writeText(t).then(function(){{ | |
| var b=document.getElementById('copy_btn_sm'); | |
| var orig=b.innerHTML; | |
| b.innerHTML='✅ 복사됨!'; | |
| b.style.background='#dcfce7'; | |
| b.style.borderColor='#86efac'; | |
| b.style.color='#166534'; | |
| setTimeout(function(){{b.innerHTML=orig;b.style.background='';b.style.borderColor='';b.style.color=''}},2000); | |
| }}); | |
| }}else{{ | |
| var ta=document.createElement('textarea'); | |
| ta.value=t;document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta); | |
| }} | |
| }})()" id="copy_btn_sm" | |
| style="background:#ffffff;border:1px solid #86efac;color:#166534;padding:8px 20px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;transition:all .2s;box-shadow:0 1px 4px rgba(0,184,148,.12);"> | |
| 📋 전체 복사 | |
| </button> | |
| </div> | |
| <div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:10px;padding:14px;max-height:200px;overflow-y:auto;font-size:13px;line-height:1.7;color:#1e293b;word-break:break-all;white-space:pre-wrap;font-family:inherit;">{safe}</div> | |
| <div style="margin-top:8px;color:#64748b;font-size:10px;display:flex;align-items:center;gap:4px;"> | |
| <span style="color:#b45309;">⚠️</span> 이 텍스트에는 눈에 보이지 않는 워터마크 문자가 포함되어 있습니다. 그대로 복사·배포하세요. | |
| </div> | |
| </div>''' |