v3:추출 파이프라인_260122
89
app.py
@@ -13,8 +13,13 @@ from flask import Flask, render_template, request, jsonify, Response, session
|
||||
from datetime import datetime
|
||||
import io
|
||||
import re
|
||||
from flask import send_file
|
||||
from datetime import datetime
|
||||
import tempfile
|
||||
from converters.pipeline.router import process_document
|
||||
from api_config import API_KEYS
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
|
||||
@@ -81,7 +86,6 @@ def get_refine_prompt():
|
||||
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
|
||||
# ============== API 호출 함수 ==============
|
||||
|
||||
def call_claude(system_prompt, user_message, max_tokens=8000):
|
||||
@@ -479,6 +483,45 @@ def hwp_script():
|
||||
"""HWP 변환 스크립트 안내"""
|
||||
return render_template('hwp_guide.html')
|
||||
|
||||
@app.route('/generate-report', methods=['POST'])
|
||||
def generate_report_api():
|
||||
"""보고서 생성 API (router 기반)"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# HTML 내용 (폴더에서 읽거나 직접 입력)
|
||||
content = data.get('content', '')
|
||||
|
||||
# 옵션
|
||||
options = {
|
||||
'folder_path': data.get('folder_path', ''),
|
||||
'cover': data.get('cover', False),
|
||||
'toc': data.get('toc', False),
|
||||
'divider': data.get('divider', False),
|
||||
'instruction': data.get('instruction', '')
|
||||
}
|
||||
|
||||
if not content.strip():
|
||||
return jsonify({'error': '내용이 비어있습니다.'}), 400
|
||||
|
||||
# router로 처리
|
||||
result = process_document(content, options)
|
||||
|
||||
if result.get('success'):
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({'error': result.get('error', '처리 실패')}), 500
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
@app.route('/assets/<path:filename>')
|
||||
def serve_assets(filename):
|
||||
"""로컬 assets 폴더 서빙"""
|
||||
assets_dir = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
|
||||
return send_file(os.path.join(assets_dir, filename))
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
@@ -486,6 +529,50 @@ def health():
|
||||
return jsonify({'status': 'healthy', 'version': '2.0.0'})
|
||||
|
||||
|
||||
# ===== HWP 변환 =====
|
||||
@app.route('/export-hwp', methods=['POST'])
|
||||
def export_hwp():
|
||||
try:
|
||||
data = request.get_json()
|
||||
html_content = data.get('html', '')
|
||||
doc_type = data.get('doc_type', 'briefing')
|
||||
|
||||
if not html_content:
|
||||
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
|
||||
|
||||
# 임시 파일 생성
|
||||
temp_dir = tempfile.gettempdir()
|
||||
html_path = os.path.join(temp_dir, 'geulbeot_temp.html')
|
||||
hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp')
|
||||
|
||||
# HTML 저장
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
# 변환기 import 및 실행
|
||||
if doc_type == 'briefing':
|
||||
from converters.html_to_hwp_briefing import HtmlToHwpConverter
|
||||
else:
|
||||
from converters.html_to_hwp import HtmlToHwpConverter
|
||||
|
||||
converter = HtmlToHwpConverter(visible=False)
|
||||
converter.convert(html_path, hwp_path)
|
||||
converter.close()
|
||||
|
||||
# 파일 전송
|
||||
return send_file(
|
||||
hwp_path,
|
||||
as_attachment=True,
|
||||
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwp',
|
||||
mimetype='application/x-hwp'
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
return jsonify({'error': f'pyhwpx 필요: {str(e)}'}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', 5000))
|
||||
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
|
||||
0
converters/__init__.py
Normal file
573
converters/html_to_hwp.py
Normal file
@@ -0,0 +1,573 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HTML → HWP 변환기 v11
|
||||
|
||||
✅ 이미지: sizeoption=0 (원본 크기) 또는 width/height 지정
|
||||
✅ 페이지번호: ctrl 코드 방식으로 수정
|
||||
✅ 나머지는 v10 유지
|
||||
|
||||
pip install pyhwpx beautifulsoup4 pillow
|
||||
"""
|
||||
|
||||
from pyhwpx import Hwp
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
import os, re
|
||||
|
||||
# PIL 선택적 import (이미지 크기 확인용)
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
print("[알림] PIL 없음 - 이미지 원본 크기로 삽입")
|
||||
|
||||
class Config:
|
||||
MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15
|
||||
HEADER_LEN, FOOTER_LEN = 10, 10
|
||||
MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비)
|
||||
|
||||
class StyleParser:
|
||||
def __init__(self):
|
||||
self.class_styles = {
|
||||
'h1': {'font-size': '20pt', 'color': '#008000'},
|
||||
'h2': {'font-size': '16pt', 'color': '#03581d'},
|
||||
'h3': {'font-size': '13pt', 'color': '#228B22'},
|
||||
'p': {'font-size': '11pt', 'color': '#333333'},
|
||||
'li': {'font-size': '11pt', 'color': '#333333'},
|
||||
'th': {'font-size': '9pt', 'color': '#006400'},
|
||||
'td': {'font-size': '9.5pt', 'color': '#333333'},
|
||||
'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'},
|
||||
'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'},
|
||||
'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'},
|
||||
}
|
||||
|
||||
def get_element_style(self, elem):
|
||||
style = {}
|
||||
tag = elem.name if hasattr(elem, 'name') else None
|
||||
if tag and tag in self.class_styles: style.update(self.class_styles[tag])
|
||||
for cls in elem.get('class', []) if hasattr(elem, 'get') else []:
|
||||
if cls in self.class_styles: style.update(self.class_styles[cls])
|
||||
return style
|
||||
|
||||
def parse_size(self, s):
|
||||
m = re.search(r'([\d.]+)', str(s)) if s else None
|
||||
return float(m.group(1)) if m else 11
|
||||
|
||||
def parse_color(self, c):
|
||||
if not c: return '#000000'
|
||||
c = str(c).strip().lower()
|
||||
if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper()
|
||||
m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c)
|
||||
return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000'
|
||||
|
||||
def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900']
|
||||
|
||||
|
||||
class HtmlToHwpConverter:
|
||||
def __init__(self, visible=True):
|
||||
self.hwp = Hwp(visible=visible)
|
||||
self.cfg = Config()
|
||||
self.sp = StyleParser()
|
||||
self.base_path = ""
|
||||
self.is_first_h1 = True
|
||||
self.image_count = 0
|
||||
|
||||
def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm)
|
||||
def _pt(self, pt): return self.hwp.PointToHwpUnit(pt)
|
||||
def _rgb(self, c):
|
||||
c = c.lstrip('#')
|
||||
return self.hwp.RGBColor(int(c[0:2],16), int(c[2:4],16), int(c[4:6],16)) if len(c)>=6 else self.hwp.RGBColor(0,0,0)
|
||||
|
||||
def _setup_page(self):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
|
||||
s = self.hwp.HParameterSet.HSecDef
|
||||
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
|
||||
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
|
||||
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
|
||||
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
|
||||
s.PageDef.HeaderLen = self._mm(self.cfg.HEADER_LEN)
|
||||
s.PageDef.FooterLen = self._mm(self.cfg.FOOTER_LEN)
|
||||
self.hwp.HAction.Execute("PageSetup", s.HSet)
|
||||
except: pass
|
||||
|
||||
def _create_header(self, right_text=""):
|
||||
print(f" → 머리말 생성: {right_text if right_text else '(초기화)'}")
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#333333')
|
||||
if right_text:
|
||||
self.hwp.insert_text(right_text)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 머리말: {e}")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 꼬리말 - 페이지 번호 (수정)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
def _create_footer(self, left_text=""):
|
||||
print(f" → 꼬리말: {left_text}")
|
||||
|
||||
# 1. 꼬리말 열기
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
# 2. 좌측 정렬 + 제목 8pt
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
|
||||
self._set_font(8, False, '#666666')
|
||||
self.hwp.insert_text(left_text)
|
||||
|
||||
# 3. 꼬리말 닫기
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
|
||||
# 4. 쪽번호 (우측 하단)
|
||||
self.hwp.HAction.GetDefault("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
|
||||
self.hwp.HParameterSet.HPageNumPos.DrawPos = self.hwp.PageNumPosition("BottomRight")
|
||||
self.hwp.HAction.Execute("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
|
||||
|
||||
def _new_section_with_header(self, header_text):
|
||||
"""새 구역 생성 후 머리말 설정"""
|
||||
print(f" → 새 구역 머리말: {header_text}")
|
||||
try:
|
||||
self.hwp.HAction.Run("BreakSection")
|
||||
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
self.hwp.HAction.Run("SelectAll")
|
||||
self.hwp.HAction.Run("Delete")
|
||||
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#333333')
|
||||
self.hwp.insert_text(header_text)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 구역 머리말: {e}")
|
||||
|
||||
|
||||
def _set_font(self, size=11, bold=False, color='#000000'):
|
||||
self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color))
|
||||
|
||||
def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0):
|
||||
acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter',
|
||||
'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'}
|
||||
if align in acts: self.hwp.HAction.Run(acts[align])
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet)
|
||||
p = self.hwp.HParameterSet.HParaShape
|
||||
p.LineSpaceType, p.LineSpacing = 0, lh
|
||||
p.LeftMargin = self._mm(left)
|
||||
p.IndentMargin = self._mm(indent)
|
||||
p.SpaceBeforePara = self._pt(before)
|
||||
p.SpaceAfterPara = self._pt(after)
|
||||
p.BreakNonLatinWord = 0
|
||||
self.hwp.HAction.Execute("ParagraphShape", p.HSet)
|
||||
except: pass
|
||||
|
||||
def _set_cell_bg(self, color):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
p = self.hwp.HParameterSet.HCellBorderFill
|
||||
p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
|
||||
p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
|
||||
p.FillAttr.WinBrushHatchColor = self._rgb('#000000')
|
||||
p.FillAttr.WinBrushFaceColor = self._rgb(color)
|
||||
p.FillAttr.WindowsBrush = 1
|
||||
self.hwp.HAction.Execute("CellBorderFill", p.HSet)
|
||||
except: pass
|
||||
|
||||
def _underline_box(self, text, size=14, color='#008000'):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet)
|
||||
t = self.hwp.HParameterSet.HTableCreation
|
||||
t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0
|
||||
t.WidthValue, t.HeightValue = self._mm(168), self._mm(10)
|
||||
self.hwp.HAction.Execute("TableCreate", t.HSet)
|
||||
self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet)
|
||||
self.hwp.HParameterSet.HInsertText.Text = text
|
||||
self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet)
|
||||
self.hwp.HAction.Run("TableCellBlock")
|
||||
self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet)
|
||||
self.hwp.HParameterSet.HCharShape.Height = self._pt(size)
|
||||
self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color)
|
||||
self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet)
|
||||
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
c = self.hwp.HParameterSet.HCellBorderFill
|
||||
c.BorderTypeTop = self.hwp.HwpLineType("None")
|
||||
c.BorderTypeRight = self.hwp.HwpLineType("None")
|
||||
c.BorderTypeLeft = self.hwp.HwpLineType("None")
|
||||
self.hwp.HAction.Execute("CellBorder", c.HSet)
|
||||
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
c = self.hwp.HParameterSet.HCellBorderFill
|
||||
c.BorderColorBottom = self._rgb(color)
|
||||
c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm")
|
||||
self.hwp.HAction.Execute("CellBorder", c.HSet)
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
except:
|
||||
self._set_font(size, True, color)
|
||||
self.hwp.insert_text(text)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _update_header(self, new_title):
|
||||
"""머리말 텍스트 업데이트"""
|
||||
try:
|
||||
# 기존 머리말 편집 모드로 진입
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 편집 모드
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
# 기존 내용 삭제
|
||||
self.hwp.HAction.Run("SelectAll")
|
||||
self.hwp.HAction.Run("Delete")
|
||||
|
||||
# 새 내용 삽입
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#333333')
|
||||
self.hwp.insert_text(new_title)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 머리말 업데이트: {e}")
|
||||
|
||||
def _insert_heading(self, elem):
|
||||
lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1
|
||||
txt = elem.get_text(strip=True)
|
||||
st = self.sp.get_element_style(elem)
|
||||
sz = self.sp.parse_size(st.get('font-size','14pt'))
|
||||
cl = self.sp.parse_color(st.get('color','#008000'))
|
||||
|
||||
if lv == 1:
|
||||
if self.is_first_h1:
|
||||
self._create_header(txt)
|
||||
self.is_first_h1 = False
|
||||
else:
|
||||
self._new_section_with_header(txt)
|
||||
|
||||
self._set_para('left', 130, before=0, after=0)
|
||||
self._underline_box(txt, sz, cl)
|
||||
self.hwp.BreakPara()
|
||||
self._set_para('left', 130, before=0, after=15)
|
||||
self.hwp.BreakPara()
|
||||
elif lv == 2:
|
||||
self._set_para('left', 150, before=20, after=8)
|
||||
self._set_font(sz, True, cl)
|
||||
self.hwp.insert_text("■ " + txt)
|
||||
self.hwp.BreakPara()
|
||||
elif lv == 3:
|
||||
self._set_para('left', 140, left=3, before=12, after=5)
|
||||
self._set_font(sz, True, cl)
|
||||
self.hwp.insert_text("▸ " + txt)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _insert_paragraph(self, elem):
|
||||
txt = elem.get_text(strip=True)
|
||||
if not txt: return
|
||||
st = self.sp.get_element_style(elem)
|
||||
sz = self.sp.parse_size(st.get('font-size','11pt'))
|
||||
cl = self.sp.parse_color(st.get('color','#333333'))
|
||||
self._set_para('justify', 170, left=0, indent=3, before=0, after=3)
|
||||
|
||||
if elem.find(['b','strong']):
|
||||
for ch in elem.children:
|
||||
if isinstance(ch, NavigableString):
|
||||
if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch))
|
||||
elif ch.name in ['b','strong']:
|
||||
if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text())
|
||||
else:
|
||||
self._set_font(sz, self.sp.is_bold(st), cl)
|
||||
self.hwp.insert_text(txt)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _insert_list(self, elem):
|
||||
lt = elem.name
|
||||
for i, li in enumerate(elem.find_all('li', recursive=False)):
|
||||
st = self.sp.get_element_style(li)
|
||||
cls = li.get('class', [])
|
||||
txt = li.get_text(strip=True)
|
||||
is_toc = any('toc-' in c for c in cls)
|
||||
|
||||
if 'toc-lvl-1' in cls: left, bef = 0, 8
|
||||
elif 'toc-lvl-2' in cls: left, bef = 7, 3
|
||||
elif 'toc-lvl-3' in cls: left, bef = 14, 1
|
||||
else: left, bef = 4, 2
|
||||
|
||||
pf = f"{i+1}. " if lt == 'ol' else "• "
|
||||
sz = self.sp.parse_size(st.get('font-size','11pt'))
|
||||
cl = self.sp.parse_color(st.get('color','#333333'))
|
||||
bd = self.sp.is_bold(st)
|
||||
|
||||
if is_toc:
|
||||
self._set_para('left', 170, left=left, indent=0, before=bef, after=1)
|
||||
self._set_font(sz, bd, cl)
|
||||
self.hwp.insert_text(pf + txt)
|
||||
self.hwp.BreakPara()
|
||||
else:
|
||||
self._set_para('justify', 170, left=left, indent=0, before=bef, after=1)
|
||||
self._set_font(sz, bd, cl)
|
||||
self.hwp.insert_text(pf)
|
||||
self.hwp.HAction.Run("ParagraphShapeIndentAtCaret")
|
||||
self.hwp.insert_text(txt)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _insert_table(self, table_elem):
|
||||
rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0
|
||||
for ri, tr in enumerate(table_elem.find_all('tr')):
|
||||
row, ci = [], 0
|
||||
for cell in tr.find_all(['td','th']):
|
||||
while (ri,ci) in occupied: row.append(""); ci+=1
|
||||
txt = cell.get_text(strip=True)
|
||||
cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1))
|
||||
cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0}
|
||||
row.append(txt)
|
||||
for dr in range(rs):
|
||||
for dc in range(cs):
|
||||
if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True
|
||||
for _ in range(cs-1): row.append("")
|
||||
ci += cs
|
||||
rows_data.append(row)
|
||||
max_cols = max(max_cols, len(row))
|
||||
for row in rows_data:
|
||||
while len(row) < max_cols: row.append("")
|
||||
|
||||
rc = len(rows_data)
|
||||
if rc == 0 or max_cols == 0: return
|
||||
print(f" 표: {rc}행 × {max_cols}열")
|
||||
|
||||
self._set_para('left', 130, before=5, after=0)
|
||||
self.hwp.create_table(rc, max_cols, treat_as_char=True)
|
||||
|
||||
for ri, row in enumerate(rows_data):
|
||||
for ci in range(max_cols):
|
||||
if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue
|
||||
txt = row[ci] if ci < len(row) else ""
|
||||
hdr = cell_styles.get((ri,ci),{}).get('is_header', False)
|
||||
if hdr: self._set_cell_bg('#E8F5E9')
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignCenter")
|
||||
self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333')
|
||||
self.hwp.insert_text(str(txt))
|
||||
if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
self._set_para('left', 130, before=5, after=5)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 이미지 삽입 - sizeoption 수정 ★
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
def _insert_image(self, src, caption=""):
|
||||
self.image_count += 1
|
||||
print(f" 📷 이미지 #{self.image_count}: {os.path.basename(src)}")
|
||||
|
||||
if not src:
|
||||
return
|
||||
|
||||
# 상대경로 → 절대경로
|
||||
if not os.path.isabs(src):
|
||||
full_path = os.path.normpath(os.path.join(self.base_path, src))
|
||||
else:
|
||||
full_path = src
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
print(f" ❌ 파일 없음: {full_path}")
|
||||
self._set_font(9, False, '#999999')
|
||||
self._set_para('center', 130)
|
||||
self.hwp.insert_text(f"[이미지 없음: {os.path.basename(src)}]")
|
||||
self.hwp.BreakPara()
|
||||
return
|
||||
|
||||
try:
|
||||
self._set_para('center', 130, before=5, after=3)
|
||||
|
||||
# ★ sizeoption=0: 원본 크기
|
||||
# ★ sizeoption=2: 지정 크기 (width, height 필요)
|
||||
# ★ 둘 다 안되면 sizeoption 없이 시도
|
||||
|
||||
inserted = False
|
||||
|
||||
# 방법 1: sizeoption=0 (원본 크기)
|
||||
try:
|
||||
self.hwp.insert_picture(full_path, sizeoption=0)
|
||||
inserted = True
|
||||
print(f" ✅ 삽입 성공 (원본 크기)")
|
||||
except Exception as e1:
|
||||
pass
|
||||
|
||||
# 방법 2: width/height 지정
|
||||
if not inserted and HAS_PIL:
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
w_px, h_px = img.size
|
||||
# px → mm 변환 (96 DPI 기준)
|
||||
w_mm = w_px * 25.4 / 96
|
||||
h_mm = h_px * 25.4 / 96
|
||||
# 최대 너비 제한
|
||||
if w_mm > self.cfg.MAX_IMAGE_WIDTH:
|
||||
ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm
|
||||
w_mm = self.cfg.MAX_IMAGE_WIDTH
|
||||
h_mm = h_mm * ratio
|
||||
|
||||
self.hwp.insert_picture(full_path, sizeoption=1,
|
||||
width=self._mm(w_mm), height=self._mm(h_mm))
|
||||
inserted = True
|
||||
print(f" ✅ 삽입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)")
|
||||
except Exception as e2:
|
||||
pass
|
||||
|
||||
# 방법 3: 기본값
|
||||
if not inserted:
|
||||
try:
|
||||
self.hwp.insert_picture(full_path)
|
||||
inserted = True
|
||||
print(f" ✅ 삽입 성공 (기본)")
|
||||
except Exception as e3:
|
||||
print(f" ❌ 삽입 실패: {e3}")
|
||||
self._set_font(9, False, '#FF0000')
|
||||
self.hwp.insert_text(f"[이미지 오류: {os.path.basename(src)}]")
|
||||
|
||||
self.hwp.BreakPara()
|
||||
|
||||
if caption and inserted:
|
||||
self._set_font(9.5, True, '#666666')
|
||||
self._set_para('center', 130, before=0, after=5)
|
||||
self.hwp.insert_text(caption)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 오류: {e}")
|
||||
|
||||
def _insert_highlight_box(self, elem):
|
||||
txt = elem.get_text(strip=True)
|
||||
if not txt: return
|
||||
self._set_para('left', 130, before=5, after=0)
|
||||
self.hwp.create_table(1, 1, treat_as_char=True)
|
||||
self._set_cell_bg('#E2ECE2')
|
||||
self._set_font(11, False, '#333333')
|
||||
self.hwp.insert_text(txt)
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
self._set_para('left', 130, before=0, after=5)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _process(self, elem):
|
||||
if isinstance(elem, NavigableString): return
|
||||
tag = elem.name
|
||||
if not tag or tag in ['script','style','template','noscript','head']: return
|
||||
|
||||
if tag == 'figure':
|
||||
img = elem.find('img')
|
||||
if img:
|
||||
figcaption = elem.find('figcaption')
|
||||
caption = figcaption.get_text(strip=True) if figcaption else ""
|
||||
self._insert_image(img.get('src', ''), caption)
|
||||
return
|
||||
|
||||
if tag == 'img':
|
||||
self._insert_image(elem.get('src', ''))
|
||||
return
|
||||
|
||||
if tag in ['h1','h2','h3']: self._insert_heading(elem)
|
||||
elif tag == 'p': self._insert_paragraph(elem)
|
||||
elif tag == 'table': self._insert_table(elem)
|
||||
elif tag in ['ul','ol']: self._insert_list(elem)
|
||||
elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem)
|
||||
elif tag in ['div','section','article','main','body','html','span']:
|
||||
for ch in elem.children: self._process(ch)
|
||||
|
||||
def convert(self, html_path, output_path):
|
||||
print("="*60)
|
||||
print("HTML → HWP 변환기 v11")
|
||||
print(" ✓ 이미지: sizeoption 수정")
|
||||
print(" ✓ 페이지번호: 다중 방법 시도")
|
||||
print("="*60)
|
||||
|
||||
self.base_path = os.path.dirname(os.path.abspath(html_path))
|
||||
self.is_first_h1 = True
|
||||
self.image_count = 0
|
||||
|
||||
print(f"\n입력: {html_path}")
|
||||
print(f"출력: {output_path}\n")
|
||||
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
soup = BeautifulSoup(f.read(), 'html.parser')
|
||||
|
||||
title_tag = soup.find('title')
|
||||
if title_tag:
|
||||
full_title = title_tag.get_text(strip=True)
|
||||
footer_title = full_title.split(':')[0].strip() # ":" 이전
|
||||
else:
|
||||
footer_title = ""
|
||||
|
||||
self.hwp.FileNew()
|
||||
self._setup_page()
|
||||
self._create_footer(footer_title)
|
||||
|
||||
raw = soup.find(id='raw-container')
|
||||
if raw:
|
||||
cover = raw.find(id='box-cover')
|
||||
if cover:
|
||||
print(" → 표지")
|
||||
for ch in cover.children: self._process(ch)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
toc = raw.find(id='box-toc')
|
||||
if toc:
|
||||
print(" → 목차")
|
||||
self.is_first_h1 = True
|
||||
self._underline_box("목 차", 20, '#008000')
|
||||
self.hwp.BreakPara(); self.hwp.BreakPara()
|
||||
self._insert_list(toc.find('ul') or toc)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
summary = raw.find(id='box-summary')
|
||||
if summary:
|
||||
print(" → 요약")
|
||||
self.is_first_h1 = True
|
||||
self._process(summary)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
content = raw.find(id='box-content')
|
||||
if content:
|
||||
print(" → 본문")
|
||||
self.is_first_h1 = True
|
||||
self._process(content)
|
||||
else:
|
||||
self._process(soup.find('body') or soup)
|
||||
|
||||
self.hwp.SaveAs(output_path)
|
||||
print(f"\n✅ 저장: {output_path}")
|
||||
print(f" 이미지: {self.image_count}개 처리")
|
||||
|
||||
def close(self):
|
||||
try: self.hwp.Quit()
|
||||
except: pass
|
||||
|
||||
|
||||
def main():
|
||||
html_path = r"D:\for python\survey_test\output\generated\report.html"
|
||||
output_path = r"D:\for python\survey_test\output\generated\report_v12.hwp"
|
||||
|
||||
try:
|
||||
conv = HtmlToHwpConverter(visible=True)
|
||||
conv.convert(html_path, output_path)
|
||||
input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항
|
||||
conv.close()
|
||||
except Exception as e:
|
||||
print(f"\n[에러] {e}")
|
||||
import traceback; traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
573
converters/html_to_hwp_briefing.py
Normal file
@@ -0,0 +1,573 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HTML → HWP 변환기 v11
|
||||
|
||||
✅ 이미지: sizeoption=0 (원본 크기) 또는 width/height 지정
|
||||
✅ 페이지번호: ctrl 코드 방식으로 수정
|
||||
✅ 나머지는 v10 유지
|
||||
|
||||
pip install pyhwpx beautifulsoup4 pillow
|
||||
"""
|
||||
|
||||
from pyhwpx import Hwp
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
import os, re
|
||||
|
||||
# PIL 선택적 import (이미지 크기 확인용)
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
print("[알림] PIL 없음 - 이미지 원본 크기로 삽입")
|
||||
|
||||
class Config:
|
||||
MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15
|
||||
HEADER_LEN, FOOTER_LEN = 10, 10
|
||||
MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비)
|
||||
|
||||
class StyleParser:
|
||||
def __init__(self):
|
||||
self.class_styles = {
|
||||
'h1': {'font-size': '20pt', 'color': '#008000'},
|
||||
'h2': {'font-size': '16pt', 'color': '#03581d'},
|
||||
'h3': {'font-size': '13pt', 'color': '#228B22'},
|
||||
'p': {'font-size': '11pt', 'color': '#333333'},
|
||||
'li': {'font-size': '11pt', 'color': '#333333'},
|
||||
'th': {'font-size': '9pt', 'color': '#006400'},
|
||||
'td': {'font-size': '9.5pt', 'color': '#333333'},
|
||||
'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'},
|
||||
'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'},
|
||||
'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'},
|
||||
}
|
||||
|
||||
def get_element_style(self, elem):
|
||||
style = {}
|
||||
tag = elem.name if hasattr(elem, 'name') else None
|
||||
if tag and tag in self.class_styles: style.update(self.class_styles[tag])
|
||||
for cls in elem.get('class', []) if hasattr(elem, 'get') else []:
|
||||
if cls in self.class_styles: style.update(self.class_styles[cls])
|
||||
return style
|
||||
|
||||
def parse_size(self, s):
|
||||
m = re.search(r'([\d.]+)', str(s)) if s else None
|
||||
return float(m.group(1)) if m else 11
|
||||
|
||||
def parse_color(self, c):
|
||||
if not c: return '#000000'
|
||||
c = str(c).strip().lower()
|
||||
if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper()
|
||||
m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c)
|
||||
return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000'
|
||||
|
||||
def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900']
|
||||
|
||||
|
||||
class HtmlToHwpConverter:
|
||||
def __init__(self, visible=True):
|
||||
self.hwp = Hwp(visible=visible)
|
||||
self.cfg = Config()
|
||||
self.sp = StyleParser()
|
||||
self.base_path = ""
|
||||
self.is_first_h1 = True
|
||||
self.image_count = 0
|
||||
|
||||
def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm)
|
||||
def _pt(self, pt): return self.hwp.PointToHwpUnit(pt)
|
||||
def _rgb(self, c):
|
||||
c = c.lstrip('#')
|
||||
return self.hwp.RGBColor(int(c[0:2],16), int(c[2:4],16), int(c[4:6],16)) if len(c)>=6 else self.hwp.RGBColor(0,0,0)
|
||||
|
||||
def _setup_page(self):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
|
||||
s = self.hwp.HParameterSet.HSecDef
|
||||
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
|
||||
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
|
||||
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
|
||||
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
|
||||
s.PageDef.HeaderLen = self._mm(self.cfg.HEADER_LEN)
|
||||
s.PageDef.FooterLen = self._mm(self.cfg.FOOTER_LEN)
|
||||
self.hwp.HAction.Execute("PageSetup", s.HSet)
|
||||
except: pass
|
||||
|
||||
def _create_header(self, right_text=""):
|
||||
print(f" → 머리말 생성: {right_text if right_text else '(초기화)'}")
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#333333')
|
||||
if right_text:
|
||||
self.hwp.insert_text(right_text)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 머리말: {e}")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 꼬리말 - 페이지 번호 (수정)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
def _create_footer(self, left_text=""):
|
||||
print(f" → 꼬리말: {left_text}")
|
||||
|
||||
# 1. 꼬리말 열기
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
# 2. 좌측 정렬 + 제목 8pt
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
|
||||
self._set_font(8, False, '#666666')
|
||||
self.hwp.insert_text(left_text)
|
||||
|
||||
# 3. 꼬리말 닫기
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
|
||||
# 4. 쪽번호 (우측 하단)
|
||||
self.hwp.HAction.GetDefault("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
|
||||
self.hwp.HParameterSet.HPageNumPos.DrawPos = self.hwp.PageNumPosition("BottomRight")
|
||||
self.hwp.HAction.Execute("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
|
||||
|
||||
def _new_section_with_header(self, header_text):
|
||||
"""새 구역 생성 후 머리말 설정"""
|
||||
print(f" → 새 구역 머리말: {header_text}")
|
||||
try:
|
||||
self.hwp.HAction.Run("BreakSection")
|
||||
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
self.hwp.HAction.Run("SelectAll")
|
||||
self.hwp.HAction.Run("Delete")
|
||||
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#333333')
|
||||
self.hwp.insert_text(header_text)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 구역 머리말: {e}")
|
||||
|
||||
|
||||
def _set_font(self, size=11, bold=False, color='#000000'):
|
||||
self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color))
|
||||
|
||||
def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0):
|
||||
acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter',
|
||||
'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'}
|
||||
if align in acts: self.hwp.HAction.Run(acts[align])
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet)
|
||||
p = self.hwp.HParameterSet.HParaShape
|
||||
p.LineSpaceType, p.LineSpacing = 0, lh
|
||||
p.LeftMargin = self._mm(left)
|
||||
p.IndentMargin = self._mm(indent)
|
||||
p.SpaceBeforePara = self._pt(before)
|
||||
p.SpaceAfterPara = self._pt(after)
|
||||
p.BreakNonLatinWord = 0
|
||||
self.hwp.HAction.Execute("ParagraphShape", p.HSet)
|
||||
except: pass
|
||||
|
||||
def _set_cell_bg(self, color):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
p = self.hwp.HParameterSet.HCellBorderFill
|
||||
p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
|
||||
p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
|
||||
p.FillAttr.WinBrushHatchColor = self._rgb('#000000')
|
||||
p.FillAttr.WinBrushFaceColor = self._rgb(color)
|
||||
p.FillAttr.WindowsBrush = 1
|
||||
self.hwp.HAction.Execute("CellBorderFill", p.HSet)
|
||||
except: pass
|
||||
|
||||
def _underline_box(self, text, size=14, color='#008000'):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet)
|
||||
t = self.hwp.HParameterSet.HTableCreation
|
||||
t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0
|
||||
t.WidthValue, t.HeightValue = self._mm(168), self._mm(10)
|
||||
self.hwp.HAction.Execute("TableCreate", t.HSet)
|
||||
self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet)
|
||||
self.hwp.HParameterSet.HInsertText.Text = text
|
||||
self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet)
|
||||
self.hwp.HAction.Run("TableCellBlock")
|
||||
self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet)
|
||||
self.hwp.HParameterSet.HCharShape.Height = self._pt(size)
|
||||
self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color)
|
||||
self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet)
|
||||
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
c = self.hwp.HParameterSet.HCellBorderFill
|
||||
c.BorderTypeTop = self.hwp.HwpLineType("None")
|
||||
c.BorderTypeRight = self.hwp.HwpLineType("None")
|
||||
c.BorderTypeLeft = self.hwp.HwpLineType("None")
|
||||
self.hwp.HAction.Execute("CellBorder", c.HSet)
|
||||
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
c = self.hwp.HParameterSet.HCellBorderFill
|
||||
c.BorderColorBottom = self._rgb(color)
|
||||
c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm")
|
||||
self.hwp.HAction.Execute("CellBorder", c.HSet)
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
except:
|
||||
self._set_font(size, True, color)
|
||||
self.hwp.insert_text(text)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _update_header(self, new_title):
|
||||
"""머리말 텍스트 업데이트"""
|
||||
try:
|
||||
# 기존 머리말 편집 모드로 진입
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 편집 모드
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
# 기존 내용 삭제
|
||||
self.hwp.HAction.Run("SelectAll")
|
||||
self.hwp.HAction.Run("Delete")
|
||||
|
||||
# 새 내용 삽입
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#333333')
|
||||
self.hwp.insert_text(new_title)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 머리말 업데이트: {e}")
|
||||
|
||||
def _insert_heading(self, elem):
|
||||
lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1
|
||||
txt = elem.get_text(strip=True)
|
||||
st = self.sp.get_element_style(elem)
|
||||
sz = self.sp.parse_size(st.get('font-size','14pt'))
|
||||
cl = self.sp.parse_color(st.get('color','#008000'))
|
||||
|
||||
if lv == 1:
|
||||
if self.is_first_h1:
|
||||
self._create_header(txt)
|
||||
self.is_first_h1 = False
|
||||
else:
|
||||
self._new_section_with_header(txt)
|
||||
|
||||
self._set_para('left', 130, before=0, after=0)
|
||||
self._underline_box(txt, sz, cl)
|
||||
self.hwp.BreakPara()
|
||||
self._set_para('left', 130, before=0, after=15)
|
||||
self.hwp.BreakPara()
|
||||
elif lv == 2:
|
||||
self._set_para('left', 150, before=20, after=8)
|
||||
self._set_font(sz, True, cl)
|
||||
self.hwp.insert_text("■ " + txt)
|
||||
self.hwp.BreakPara()
|
||||
elif lv == 3:
|
||||
self._set_para('left', 140, left=3, before=12, after=5)
|
||||
self._set_font(sz, True, cl)
|
||||
self.hwp.insert_text("▸ " + txt)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _insert_paragraph(self, elem):
|
||||
txt = elem.get_text(strip=True)
|
||||
if not txt: return
|
||||
st = self.sp.get_element_style(elem)
|
||||
sz = self.sp.parse_size(st.get('font-size','11pt'))
|
||||
cl = self.sp.parse_color(st.get('color','#333333'))
|
||||
self._set_para('justify', 170, left=0, indent=3, before=0, after=3)
|
||||
|
||||
if elem.find(['b','strong']):
|
||||
for ch in elem.children:
|
||||
if isinstance(ch, NavigableString):
|
||||
if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch))
|
||||
elif ch.name in ['b','strong']:
|
||||
if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text())
|
||||
else:
|
||||
self._set_font(sz, self.sp.is_bold(st), cl)
|
||||
self.hwp.insert_text(txt)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _insert_list(self, elem):
|
||||
lt = elem.name
|
||||
for i, li in enumerate(elem.find_all('li', recursive=False)):
|
||||
st = self.sp.get_element_style(li)
|
||||
cls = li.get('class', [])
|
||||
txt = li.get_text(strip=True)
|
||||
is_toc = any('toc-' in c for c in cls)
|
||||
|
||||
if 'toc-lvl-1' in cls: left, bef = 0, 8
|
||||
elif 'toc-lvl-2' in cls: left, bef = 7, 3
|
||||
elif 'toc-lvl-3' in cls: left, bef = 14, 1
|
||||
else: left, bef = 4, 2
|
||||
|
||||
pf = f"{i+1}. " if lt == 'ol' else "• "
|
||||
sz = self.sp.parse_size(st.get('font-size','11pt'))
|
||||
cl = self.sp.parse_color(st.get('color','#333333'))
|
||||
bd = self.sp.is_bold(st)
|
||||
|
||||
if is_toc:
|
||||
self._set_para('left', 170, left=left, indent=0, before=bef, after=1)
|
||||
self._set_font(sz, bd, cl)
|
||||
self.hwp.insert_text(pf + txt)
|
||||
self.hwp.BreakPara()
|
||||
else:
|
||||
self._set_para('justify', 170, left=left, indent=0, before=bef, after=1)
|
||||
self._set_font(sz, bd, cl)
|
||||
self.hwp.insert_text(pf)
|
||||
self.hwp.HAction.Run("ParagraphShapeIndentAtCaret")
|
||||
self.hwp.insert_text(txt)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _insert_table(self, table_elem):
|
||||
rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0
|
||||
for ri, tr in enumerate(table_elem.find_all('tr')):
|
||||
row, ci = [], 0
|
||||
for cell in tr.find_all(['td','th']):
|
||||
while (ri,ci) in occupied: row.append(""); ci+=1
|
||||
txt = cell.get_text(strip=True)
|
||||
cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1))
|
||||
cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0}
|
||||
row.append(txt)
|
||||
for dr in range(rs):
|
||||
for dc in range(cs):
|
||||
if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True
|
||||
for _ in range(cs-1): row.append("")
|
||||
ci += cs
|
||||
rows_data.append(row)
|
||||
max_cols = max(max_cols, len(row))
|
||||
for row in rows_data:
|
||||
while len(row) < max_cols: row.append("")
|
||||
|
||||
rc = len(rows_data)
|
||||
if rc == 0 or max_cols == 0: return
|
||||
print(f" 표: {rc}행 × {max_cols}열")
|
||||
|
||||
self._set_para('left', 130, before=5, after=0)
|
||||
self.hwp.create_table(rc, max_cols, treat_as_char=True)
|
||||
|
||||
for ri, row in enumerate(rows_data):
|
||||
for ci in range(max_cols):
|
||||
if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue
|
||||
txt = row[ci] if ci < len(row) else ""
|
||||
hdr = cell_styles.get((ri,ci),{}).get('is_header', False)
|
||||
if hdr: self._set_cell_bg('#E8F5E9')
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignCenter")
|
||||
self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333')
|
||||
self.hwp.insert_text(str(txt))
|
||||
if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
self._set_para('left', 130, before=5, after=5)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 이미지 삽입 - sizeoption 수정 ★
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
def _insert_image(self, src, caption=""):
|
||||
self.image_count += 1
|
||||
print(f" 📷 이미지 #{self.image_count}: {os.path.basename(src)}")
|
||||
|
||||
if not src:
|
||||
return
|
||||
|
||||
# 상대경로 → 절대경로
|
||||
if not os.path.isabs(src):
|
||||
full_path = os.path.normpath(os.path.join(self.base_path, src))
|
||||
else:
|
||||
full_path = src
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
print(f" ❌ 파일 없음: {full_path}")
|
||||
self._set_font(9, False, '#999999')
|
||||
self._set_para('center', 130)
|
||||
self.hwp.insert_text(f"[이미지 없음: {os.path.basename(src)}]")
|
||||
self.hwp.BreakPara()
|
||||
return
|
||||
|
||||
try:
|
||||
self._set_para('center', 130, before=5, after=3)
|
||||
|
||||
# ★ sizeoption=0: 원본 크기
|
||||
# ★ sizeoption=2: 지정 크기 (width, height 필요)
|
||||
# ★ 둘 다 안되면 sizeoption 없이 시도
|
||||
|
||||
inserted = False
|
||||
|
||||
# 방법 1: sizeoption=0 (원본 크기)
|
||||
try:
|
||||
self.hwp.insert_picture(full_path, sizeoption=0)
|
||||
inserted = True
|
||||
print(f" ✅ 삽입 성공 (원본 크기)")
|
||||
except Exception as e1:
|
||||
pass
|
||||
|
||||
# 방법 2: width/height 지정
|
||||
if not inserted and HAS_PIL:
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
w_px, h_px = img.size
|
||||
# px → mm 변환 (96 DPI 기준)
|
||||
w_mm = w_px * 25.4 / 96
|
||||
h_mm = h_px * 25.4 / 96
|
||||
# 최대 너비 제한
|
||||
if w_mm > self.cfg.MAX_IMAGE_WIDTH:
|
||||
ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm
|
||||
w_mm = self.cfg.MAX_IMAGE_WIDTH
|
||||
h_mm = h_mm * ratio
|
||||
|
||||
self.hwp.insert_picture(full_path, sizeoption=1,
|
||||
width=self._mm(w_mm), height=self._mm(h_mm))
|
||||
inserted = True
|
||||
print(f" ✅ 삽입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)")
|
||||
except Exception as e2:
|
||||
pass
|
||||
|
||||
# 방법 3: 기본값
|
||||
if not inserted:
|
||||
try:
|
||||
self.hwp.insert_picture(full_path)
|
||||
inserted = True
|
||||
print(f" ✅ 삽입 성공 (기본)")
|
||||
except Exception as e3:
|
||||
print(f" ❌ 삽입 실패: {e3}")
|
||||
self._set_font(9, False, '#FF0000')
|
||||
self.hwp.insert_text(f"[이미지 오류: {os.path.basename(src)}]")
|
||||
|
||||
self.hwp.BreakPara()
|
||||
|
||||
if caption and inserted:
|
||||
self._set_font(9.5, True, '#666666')
|
||||
self._set_para('center', 130, before=0, after=5)
|
||||
self.hwp.insert_text(caption)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 오류: {e}")
|
||||
|
||||
def _insert_highlight_box(self, elem):
|
||||
txt = elem.get_text(strip=True)
|
||||
if not txt: return
|
||||
self._set_para('left', 130, before=5, after=0)
|
||||
self.hwp.create_table(1, 1, treat_as_char=True)
|
||||
self._set_cell_bg('#E2ECE2')
|
||||
self._set_font(11, False, '#333333')
|
||||
self.hwp.insert_text(txt)
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
self._set_para('left', 130, before=0, after=5)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _process(self, elem):
|
||||
if isinstance(elem, NavigableString): return
|
||||
tag = elem.name
|
||||
if not tag or tag in ['script','style','template','noscript','head']: return
|
||||
|
||||
if tag == 'figure':
|
||||
img = elem.find('img')
|
||||
if img:
|
||||
figcaption = elem.find('figcaption')
|
||||
caption = figcaption.get_text(strip=True) if figcaption else ""
|
||||
self._insert_image(img.get('src', ''), caption)
|
||||
return
|
||||
|
||||
if tag == 'img':
|
||||
self._insert_image(elem.get('src', ''))
|
||||
return
|
||||
|
||||
if tag in ['h1','h2','h3']: self._insert_heading(elem)
|
||||
elif tag == 'p': self._insert_paragraph(elem)
|
||||
elif tag == 'table': self._insert_table(elem)
|
||||
elif tag in ['ul','ol']: self._insert_list(elem)
|
||||
elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem)
|
||||
elif tag in ['div','section','article','main','body','html','span']:
|
||||
for ch in elem.children: self._process(ch)
|
||||
|
||||
def convert(self, html_path, output_path):
|
||||
print("="*60)
|
||||
print("HTML → HWP 변환기 v11")
|
||||
print(" ✓ 이미지: sizeoption 수정")
|
||||
print(" ✓ 페이지번호: 다중 방법 시도")
|
||||
print("="*60)
|
||||
|
||||
self.base_path = os.path.dirname(os.path.abspath(html_path))
|
||||
self.is_first_h1 = True
|
||||
self.image_count = 0
|
||||
|
||||
print(f"\n입력: {html_path}")
|
||||
print(f"출력: {output_path}\n")
|
||||
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
soup = BeautifulSoup(f.read(), 'html.parser')
|
||||
|
||||
title_tag = soup.find('title')
|
||||
if title_tag:
|
||||
full_title = title_tag.get_text(strip=True)
|
||||
footer_title = full_title.split(':')[0].strip() # ":" 이전
|
||||
else:
|
||||
footer_title = ""
|
||||
|
||||
self.hwp.FileNew()
|
||||
self._setup_page()
|
||||
self._create_footer(footer_title)
|
||||
|
||||
raw = soup.find(id='raw-container')
|
||||
if raw:
|
||||
cover = raw.find(id='box-cover')
|
||||
if cover:
|
||||
print(" → 표지")
|
||||
for ch in cover.children: self._process(ch)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
toc = raw.find(id='box-toc')
|
||||
if toc:
|
||||
print(" → 목차")
|
||||
self.is_first_h1 = True
|
||||
self._underline_box("목 차", 20, '#008000')
|
||||
self.hwp.BreakPara(); self.hwp.BreakPara()
|
||||
self._insert_list(toc.find('ul') or toc)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
summary = raw.find(id='box-summary')
|
||||
if summary:
|
||||
print(" → 요약")
|
||||
self.is_first_h1 = True
|
||||
self._process(summary)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
content = raw.find(id='box-content')
|
||||
if content:
|
||||
print(" → 본문")
|
||||
self.is_first_h1 = True
|
||||
self._process(content)
|
||||
else:
|
||||
self._process(soup.find('body') or soup)
|
||||
|
||||
self.hwp.SaveAs(output_path)
|
||||
print(f"\n✅ 저장: {output_path}")
|
||||
print(f" 이미지: {self.image_count}개 처리")
|
||||
|
||||
def close(self):
|
||||
try: self.hwp.Quit()
|
||||
except: pass
|
||||
|
||||
|
||||
def main():
|
||||
html_path = r"D:\for python\survey_test\output\generated\report.html"
|
||||
output_path = r"D:\for python\survey_test\output\generated\report_v12.hwp"
|
||||
|
||||
try:
|
||||
conv = HtmlToHwpConverter(visible=True)
|
||||
conv.convert(html_path, output_path)
|
||||
input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항
|
||||
conv.close()
|
||||
except Exception as e:
|
||||
print(f"\n[에러] {e}")
|
||||
import traceback; traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
converters/pipeline/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .router import process_document, is_long_document
|
||||
139
converters/pipeline/router.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
router.py
|
||||
|
||||
기능:
|
||||
- HTML 입력의 분량을 판단하여 적절한 파이프라인으로 분기
|
||||
- 긴 문서 (5000자 이상): RAG 파이프라인 (step3→4→5→6→7→8→9)
|
||||
- 짧은 문서 (5000자 미만): 직접 생성 (step7→8→9)
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
# 분량 판단 기준
|
||||
LONG_DOC_THRESHOLD = 5000 # 5000자 이상이면 긴 문서
|
||||
|
||||
# 이미지 assets 경로 (개발용 고정) - r prefix 필수!
|
||||
ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
|
||||
|
||||
def count_characters(html_content: str) -> int:
|
||||
"""HTML 태그 제외한 순수 텍스트 글자 수 계산"""
|
||||
# HTML 태그 제거
|
||||
text_only = re.sub(r'<[^>]+>', '', html_content)
|
||||
# 공백 정리
|
||||
text_only = ' '.join(text_only.split())
|
||||
return len(text_only)
|
||||
|
||||
|
||||
def is_long_document(html_content: str) -> bool:
|
||||
"""긴 문서 여부 판단"""
|
||||
char_count = count_characters(html_content)
|
||||
return char_count >= LONG_DOC_THRESHOLD
|
||||
|
||||
def convert_image_paths(html_content: str) -> str:
|
||||
"""
|
||||
HTML 내 상대 이미지 경로를 서버 경로로 변환
|
||||
assets/xxx.png → /assets/xxx.png
|
||||
"""
|
||||
result = re.sub(r'src="assets/', 'src="/assets/', html_content)
|
||||
return result
|
||||
|
||||
def replace_src(match):
|
||||
original_path = match.group(1)
|
||||
# 이미 절대 경로이거나 URL이면 그대로
|
||||
if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:')):
|
||||
return match.group(0)
|
||||
|
||||
# assets/로 시작하면 절대 경로로 변환
|
||||
if original_path.startswith('assets/'):
|
||||
filename = original_path.replace('assets/', '')
|
||||
absolute_path = os.path.join(ASSETS_BASE_PATH, filename)
|
||||
return f'src="{absolute_path}"'
|
||||
|
||||
return match.group(0)
|
||||
|
||||
# src="..." 패턴 찾아서 변환
|
||||
result = re.sub(r'src="([^"]+)"', replace_src, html_content)
|
||||
return result
|
||||
|
||||
def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
짧은 문서 파이프라인 (5000자 미만)
|
||||
"""
|
||||
try:
|
||||
# 이미지 경로 변환
|
||||
processed_html = convert_image_paths(html_content)
|
||||
|
||||
# TODO: step7, step8, step9 연동
|
||||
return {
|
||||
'success': True,
|
||||
'pipeline': 'short',
|
||||
'char_count': count_characters(html_content),
|
||||
'html': processed_html
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'pipeline': 'short'
|
||||
}
|
||||
|
||||
|
||||
def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
긴 문서 파이프라인 (5000자 이상)
|
||||
"""
|
||||
try:
|
||||
# 이미지 경로 변환
|
||||
processed_html = convert_image_paths(html_content)
|
||||
|
||||
# TODO: step3~9 순차 실행
|
||||
return {
|
||||
'success': True,
|
||||
'pipeline': 'long',
|
||||
'char_count': count_characters(html_content),
|
||||
'html': processed_html
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'pipeline': 'long'
|
||||
}
|
||||
|
||||
|
||||
def process_document(content: str, options: dict = None) -> Dict[str, Any]:
|
||||
"""
|
||||
메인 라우터 함수
|
||||
- 분량에 따라 적절한 파이프라인으로 분기
|
||||
|
||||
Args:
|
||||
content: HTML 문자열
|
||||
options: 추가 옵션 (page_option, instruction 등)
|
||||
|
||||
Returns:
|
||||
{'success': bool, 'html': str, 'pipeline': str, ...}
|
||||
"""
|
||||
if options is None:
|
||||
options = {}
|
||||
|
||||
if not content or not content.strip():
|
||||
return {
|
||||
'success': False,
|
||||
'error': '내용이 비어있습니다.'
|
||||
}
|
||||
|
||||
char_count = count_characters(content)
|
||||
|
||||
if is_long_document(content):
|
||||
result = run_long_pipeline(content, options)
|
||||
else:
|
||||
result = run_short_pipeline(content, options)
|
||||
|
||||
# 공통 정보 추가
|
||||
result['char_count'] = char_count
|
||||
result['threshold'] = LONG_DOC_THRESHOLD
|
||||
|
||||
return result
|
||||
784
converters/pipeline/step1_convert.py
Normal file
@@ -0,0 +1,784 @@
|
||||
"""
|
||||
측량/GIS/드론 관련 자료 PDF 변환 및 정리 시스템
|
||||
- 모든 파일 형식을 PDF로 변환
|
||||
- DWG 파일: DWG TrueView를 사용한 자동 PDF 변환
|
||||
- 동영상 파일: Whisper를 사용한 음성→텍스트 변환 후 PDF 생성
|
||||
- 원본 경로와 변환 파일 경로를 엑셀로 관리
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
import win32com.client
|
||||
import pythoncom
|
||||
from PIL import Image
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
class SurveyingFileConverter:
|
||||
def _dbg(self, msg):
|
||||
if getattr(self, "debug", False):
|
||||
print(msg)
|
||||
|
||||
def _ensure_ffmpeg_on_path(self):
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
found = shutil.which("ffmpeg")
|
||||
self._dbg(f"DEBUG ffmpeg which before: {found}")
|
||||
if found:
|
||||
self.ffmpeg_exe = found
|
||||
return True
|
||||
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
|
||||
src = Path(imageio_ffmpeg.get_ffmpeg_exe())
|
||||
self._dbg(f"DEBUG imageio ffmpeg exe: {src}")
|
||||
self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}")
|
||||
|
||||
if not src.exists():
|
||||
return False
|
||||
|
||||
tools_dir = Path(self.output_dir) / "tools_ffmpeg"
|
||||
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dst = tools_dir / "ffmpeg.exe"
|
||||
|
||||
if not dst.exists():
|
||||
shutil.copyfile(str(src), str(dst))
|
||||
|
||||
os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
found2 = shutil.which("ffmpeg")
|
||||
self._dbg(f"DEBUG ffmpeg which after: {found2}")
|
||||
|
||||
if found2:
|
||||
self.ffmpeg_exe = found2
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self._dbg(f"DEBUG ensure ffmpeg error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def __init__(self, source_dir, output_dir):
|
||||
self.source_dir = Path(source_dir)
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.debug = True
|
||||
self.ffmpeg_exe = None
|
||||
ok = self._ensure_ffmpeg_on_path()
|
||||
self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}")
|
||||
|
||||
# 변환 로그를 저장할 리스트
|
||||
self.conversion_log = []
|
||||
|
||||
# ★ 추가: 도메인 용어 사전
|
||||
self.domain_terms = ""
|
||||
|
||||
# HWP 보안 모듈 후보 목록 추가
|
||||
self.hwp_security_modules = [
|
||||
"FilePathCheckerModuleExample",
|
||||
"SecurityModule",
|
||||
""
|
||||
]
|
||||
|
||||
# 지원 파일 확장자 정의
|
||||
self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'}
|
||||
self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'}
|
||||
self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'}
|
||||
self.text_extensions = {'.txt', '.csv', '.log', '.md'}
|
||||
self.pdf_extension = {'.pdf'}
|
||||
self.dwg_extensions = {'.dwg', '.dxf'}
|
||||
|
||||
# DWG TrueView 경로 설정 (설치 버전에 맞게 조정)
|
||||
self.trueview_path = self._find_trueview()
|
||||
|
||||
def _find_trueview(self):
|
||||
"""DWG TrueView 설치 경로 자동 탐색"""
|
||||
possible_paths = [
|
||||
r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe",
|
||||
r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe",
|
||||
r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe",
|
||||
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe",
|
||||
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def get_all_files(self):
|
||||
"""하위 모든 폴더의 파일 목록 가져오기"""
|
||||
all_files = []
|
||||
for file_path in self.source_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
all_files.append(file_path)
|
||||
return all_files
|
||||
|
||||
def extract_audio_from_video(self, video_path, audio_output_path):
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
from pathlib import Path
|
||||
|
||||
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}")
|
||||
self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}")
|
||||
self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}")
|
||||
self._dbg(f"DEBUG extract out path: {audio_output_path}")
|
||||
|
||||
cmd = [
|
||||
ffmpeg_exe,
|
||||
"-i", str(video_path),
|
||||
"-vn",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-y",
|
||||
str(audio_output_path),
|
||||
]
|
||||
self._dbg("DEBUG extract cmd: " + " ".join(cmd))
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True)
|
||||
self._dbg(f"DEBUG extract returncode: {result.returncode}")
|
||||
self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}")
|
||||
self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self._dbg(f"DEBUG extract exception: {e}")
|
||||
return False
|
||||
|
||||
def transcribe_audio_with_whisper(self, audio_path):
|
||||
try:
|
||||
self._ensure_ffmpeg_on_path()
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}")
|
||||
|
||||
if not ffmpeg_path:
|
||||
if self.ffmpeg_exe:
|
||||
import os
|
||||
os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
audio_file = Path(audio_path)
|
||||
self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}")
|
||||
self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}")
|
||||
|
||||
if not audio_file.exists() or audio_file.stat().st_size == 0:
|
||||
return "[오디오 파일이 비어있거나 존재하지 않음]"
|
||||
|
||||
import whisper
|
||||
model = whisper.load_model("medium") # ★ base → medium 변경
|
||||
|
||||
# ★ domain_terms를 initial_prompt로 사용
|
||||
result = model.transcribe(
|
||||
str(audio_path),
|
||||
language="ko",
|
||||
task="transcribe",
|
||||
initial_prompt=self.domain_terms if self.domain_terms else None,
|
||||
condition_on_previous_text=True, # ★ 다시 True로
|
||||
)
|
||||
|
||||
# ★ 후처리: 반복 및 이상한 텍스트 제거
|
||||
text = result["text"]
|
||||
text = self.clean_transcript(text)
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}")
|
||||
return f"[음성 인식 실패: {str(e)}]"
|
||||
|
||||
def clean_transcript(self, text):
|
||||
"""Whisper 결과 후처리 - 반복/환각 제거"""
|
||||
import re
|
||||
|
||||
# 1. 영어/일본어/중국어 환각 제거
|
||||
text = re.sub(r'[A-Za-z]{3,}', '', text) # 3글자 이상 영어 제거
|
||||
text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 일본어 제거
|
||||
text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시)
|
||||
|
||||
# 2. 반복 문장 제거
|
||||
sentences = text.split('.')
|
||||
seen = set()
|
||||
unique_sentences = []
|
||||
for s in sentences:
|
||||
s_clean = s.strip()
|
||||
if s_clean and s_clean not in seen:
|
||||
seen.add(s_clean)
|
||||
unique_sentences.append(s_clean)
|
||||
|
||||
text = '. '.join(unique_sentences)
|
||||
|
||||
# 3. 이상한 문자 정리
|
||||
text = re.sub(r'\s+', ' ', text) # 다중 공백 제거
|
||||
text = text.strip()
|
||||
|
||||
return text
|
||||
|
||||
def get_video_transcript(self, video_path):
|
||||
"""동영상 파일의 음성을 텍스트로 변환"""
|
||||
try:
|
||||
# 임시 오디오 파일 경로
|
||||
temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav"
|
||||
|
||||
# 1. 동영상에서 오디오 추출
|
||||
if not self.extract_audio_from_video(video_path, temp_audio):
|
||||
return self.get_basic_file_info(video_path) + "\n\n[오디오 추출 실패]"
|
||||
if (not temp_audio.exists()) or temp_audio.stat().st_size == 0:
|
||||
return self.get_basic_file_info(video_path) + "\n\n[오디오 파일 생성 실패]"
|
||||
|
||||
# 2. Whisper로 음성 인식
|
||||
transcript = self.transcribe_audio_with_whisper(temp_audio)
|
||||
|
||||
# 3. 임시 오디오 파일 삭제
|
||||
if temp_audio.exists():
|
||||
temp_audio.unlink()
|
||||
|
||||
# 4. 결과 포맷팅
|
||||
stat = video_path.stat()
|
||||
lines = []
|
||||
lines.append(f"동영상 파일 음성 전사 (Speech-to-Text)")
|
||||
lines.append(f"=" * 60)
|
||||
lines.append(f"파일명: {video_path.name}")
|
||||
lines.append(f"경로: {video_path}")
|
||||
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
|
||||
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append("")
|
||||
lines.append("=" * 60)
|
||||
lines.append("음성 내용:")
|
||||
lines.append("=" * 60)
|
||||
lines.append("")
|
||||
lines.append(transcript)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except Exception as e:
|
||||
return self.get_basic_file_info(video_path) + f"\n\n[음성 인식 오류: {str(e)}]"
|
||||
|
||||
def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path):
|
||||
"""DWG TrueView를 사용한 DWG → PDF 변환"""
|
||||
if not self.trueview_path:
|
||||
return False, "DWG TrueView가 설치되지 않음"
|
||||
|
||||
try:
|
||||
# AutoCAD 스크립트 생성
|
||||
script_content = f"""_-EXPORT_PDF{pdf_path}_Y"""
|
||||
script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr"
|
||||
with open(script_path, 'w') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# TrueView 실행
|
||||
cmd = [
|
||||
self.trueview_path,
|
||||
str(dwg_path.absolute()),
|
||||
"/b", str(script_path.absolute()),
|
||||
"/nologo"
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, timeout=120, capture_output=True)
|
||||
|
||||
# 스크립트 파일 삭제
|
||||
if script_path.exists():
|
||||
try:
|
||||
script_path.unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
# PDF 생성 확인
|
||||
if pdf_path.exists():
|
||||
return True, "성공"
|
||||
else:
|
||||
return False, "PDF 생성 실패"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "변환 시간 초과"
|
||||
except Exception as e:
|
||||
return False, f"DWG 변환 실패: {str(e)}"
|
||||
|
||||
def get_basic_file_info(self, file_path):
|
||||
"""기본 파일 정보 반환"""
|
||||
stat = file_path.stat()
|
||||
lines = []
|
||||
lines.append(f"파일 정보")
|
||||
lines.append(f"=" * 60)
|
||||
lines.append(f"파일명: {file_path.name}")
|
||||
lines.append(f"경로: {file_path}")
|
||||
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
|
||||
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append(f"수정일: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_file_size(self, size_bytes):
|
||||
"""파일 크기를 읽기 쉬운 형식으로 변환"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.2f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.2f} TB"
|
||||
|
||||
def convert_image_to_pdf(self, image_path, output_path):
|
||||
"""이미지 파일을 PDF로 변환"""
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
# RGB 모드로 변환 (RGBA나 다른 모드 처리)
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
# 흰색 배경 생성
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
img.save(output_path, 'PDF', resolution=100.0)
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"이미지 변환 실패: {str(e)}"
|
||||
|
||||
def convert_office_to_pdf(self, file_path, output_path):
|
||||
"""Office 문서를 PDF로 변환"""
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext in {'.hwp', '.hwpx'}:
|
||||
return self.convert_hwp_to_pdf(file_path, output_path)
|
||||
elif ext in {'.doc', '.docx'}:
|
||||
return self.convert_word_to_pdf(file_path, output_path)
|
||||
elif ext in {'.xls', '.xlsx'}:
|
||||
return self.convert_excel_to_pdf(file_path, output_path)
|
||||
elif ext in {'.ppt', '.pptx'}:
|
||||
return self.convert_ppt_to_pdf(file_path, output_path)
|
||||
else:
|
||||
return False, "지원하지 않는 Office 형식"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Office 변환 실패: {str(e)}"
|
||||
finally:
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
def convert_word_to_pdf(self, file_path, output_path):
|
||||
"""Word 문서를 PDF로 변환"""
|
||||
try:
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
doc = word.Documents.Open(str(file_path.absolute()))
|
||||
doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"Word 변환 실패: {str(e)}"
|
||||
|
||||
def convert_excel_to_pdf(self, file_path, output_path):
|
||||
"""Excel 파일을 PDF로 변환 - 열 너비에 맞춰 출력"""
|
||||
try:
|
||||
excel = win32com.client.Dispatch("Excel.Application")
|
||||
excel.Visible = False
|
||||
wb = excel.Workbooks.Open(str(file_path.absolute()))
|
||||
|
||||
# 모든 시트에 대해 페이지 설정
|
||||
for ws in wb.Worksheets:
|
||||
# 페이지 설정
|
||||
ws.PageSetup.Zoom = False # 자동 크기 조정 비활성화
|
||||
ws.PageSetup.FitToPagesWide = 1 # 너비를 1페이지에 맞춤
|
||||
ws.PageSetup.FitToPagesTall = False # 높이는 자동 (내용에 따라)
|
||||
|
||||
# 여백 최소화 (단위: 포인트, 1cm ≈ 28.35 포인트)
|
||||
ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1)
|
||||
ws.PageSetup.RightMargin = excel.CentimetersToPoints(1)
|
||||
ws.PageSetup.TopMargin = excel.CentimetersToPoints(1)
|
||||
ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1)
|
||||
|
||||
# 용지 방향 자동 결정 (가로가 긴 경우 가로 방향)
|
||||
used_range = ws.UsedRange
|
||||
if used_range.Columns.Count > used_range.Rows.Count:
|
||||
ws.PageSetup.Orientation = 2 # xlLandscape (가로)
|
||||
else:
|
||||
ws.PageSetup.Orientation = 1 # xlPortrait (세로)
|
||||
|
||||
# PDF로 저장
|
||||
wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF
|
||||
wb.Close()
|
||||
excel.Quit()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"Excel 변환 실패: {str(e)}"
|
||||
|
||||
|
||||
def convert_ppt_to_pdf(self, file_path, output_path):
|
||||
"""PowerPoint 파일을 PDF로 변환"""
|
||||
try:
|
||||
ppt = win32com.client.Dispatch("PowerPoint.Application")
|
||||
ppt.Visible = True
|
||||
presentation = ppt.Presentations.Open(str(file_path.absolute()))
|
||||
presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF
|
||||
presentation.Close()
|
||||
ppt.Quit()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"PowerPoint 변환 실패: {str(e)}"
|
||||
|
||||
def convert_hwp_to_pdf(self, file_path, output_path):
|
||||
hwp = None
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject")
|
||||
except Exception:
|
||||
hwp = win32com.client.Dispatch("HWPFrame.HwpObject")
|
||||
|
||||
registered = False
|
||||
last_reg_error = None
|
||||
|
||||
for module_name in getattr(self, "hwp_security_modules", [""]):
|
||||
try:
|
||||
hwp.RegisterModule("FilePathCheckDLL", module_name)
|
||||
registered = True
|
||||
break
|
||||
except Exception as e:
|
||||
last_reg_error = e
|
||||
|
||||
if not registered:
|
||||
return False, f"HWP 보안 모듈 등록 실패: {last_reg_error}"
|
||||
|
||||
hwp.Open(str(file_path.absolute()), "", "")
|
||||
|
||||
hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
|
||||
hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute())
|
||||
hwp.HParameterSet.HFileOpenSave.Format = "PDF"
|
||||
hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
|
||||
|
||||
if output_path.exists() and output_path.stat().st_size > 0:
|
||||
return True, "성공"
|
||||
return False, "PDF 생성 확인 실패"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"HWP 변환 실패: {str(e)}"
|
||||
finally:
|
||||
try:
|
||||
if hwp:
|
||||
try:
|
||||
hwp.Clear(1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
hwp.Quit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def convert_text_to_pdf(self, text_path, output_path):
|
||||
"""텍스트 파일을 PDF로 변환 (reportlab 사용)"""
|
||||
try:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
# 한글 폰트 등록 (시스템에 설치된 폰트 사용)
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf'))
|
||||
font_name = 'Malgun'
|
||||
except:
|
||||
font_name = 'Helvetica'
|
||||
|
||||
# 텍스트 읽기
|
||||
with open(text_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# PDF 생성
|
||||
c = canvas.Canvas(str(output_path), pagesize=A4)
|
||||
width, height = A4
|
||||
|
||||
c.setFont(font_name, 10)
|
||||
|
||||
# 여백 설정
|
||||
margin = 50
|
||||
y = height - margin
|
||||
line_height = 14
|
||||
|
||||
# 줄 단위로 처리
|
||||
for line in content.split('\n'):
|
||||
if y < margin: # 페이지 넘김
|
||||
c.showPage()
|
||||
c.setFont(font_name, 10)
|
||||
y = height - margin
|
||||
|
||||
# 긴 줄은 자동으로 줄바꿈
|
||||
if len(line) > 100:
|
||||
chunks = [line[i:i+100] for i in range(0, len(line), 100)]
|
||||
for chunk in chunks:
|
||||
c.drawString(margin, y, chunk)
|
||||
y -= line_height
|
||||
else:
|
||||
c.drawString(margin, y, line)
|
||||
y -= line_height
|
||||
|
||||
c.save()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"텍스트 변환 실패: {str(e)}"
|
||||
|
||||
def process_file(self, file_path):
|
||||
"""개별 파일 처리"""
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
# 출력 파일명 생성 (원본 경로 구조 유지)
|
||||
relative_path = file_path.relative_to(self.source_dir)
|
||||
output_subdir = self.output_dir / relative_path.parent
|
||||
output_subdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# PDF 파일명
|
||||
output_pdf = output_subdir / f"{file_path.stem}.pdf"
|
||||
|
||||
success = False
|
||||
message = ""
|
||||
|
||||
try:
|
||||
# 이미 PDF인 경우
|
||||
if ext in self.pdf_extension:
|
||||
shutil.copy2(file_path, output_pdf)
|
||||
success = True
|
||||
message = "PDF 복사 완료"
|
||||
|
||||
# DWG/DXF 파일
|
||||
elif ext in self.dwg_extensions:
|
||||
success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf)
|
||||
|
||||
# 이미지 파일
|
||||
elif ext in self.image_extensions:
|
||||
success, message = self.convert_image_to_pdf(file_path, output_pdf)
|
||||
|
||||
# Office 문서
|
||||
elif ext in self.office_extensions:
|
||||
success, message = self.convert_office_to_pdf(file_path, output_pdf)
|
||||
|
||||
# 동영상 파일 - 음성을 텍스트로 변환 후 PDF 생성
|
||||
elif ext in self.video_extensions:
|
||||
# 음성→텍스트 변환
|
||||
transcript_text = self.get_video_transcript(file_path)
|
||||
|
||||
# 임시 txt 파일 생성
|
||||
temp_txt = output_subdir / f"{file_path.stem}_transcript.txt"
|
||||
with open(temp_txt, 'w', encoding='utf-8') as f:
|
||||
f.write(transcript_text)
|
||||
|
||||
# txt를 PDF로 변환
|
||||
success, message = self.convert_text_to_pdf(temp_txt, output_pdf)
|
||||
|
||||
if success:
|
||||
message = "성공 (음성 인식 완료)"
|
||||
|
||||
# 임시 txt 파일은 남겨둠 (참고용)
|
||||
|
||||
# 텍스트 파일
|
||||
elif ext in self.text_extensions:
|
||||
success, message = self.convert_text_to_pdf(file_path, output_pdf)
|
||||
|
||||
else:
|
||||
message = f"지원하지 않는 파일 형식: {ext}"
|
||||
|
||||
except Exception as e:
|
||||
message = f"처리 중 오류: {str(e)}"
|
||||
|
||||
# 로그 기록
|
||||
self.conversion_log.append({
|
||||
'원본 경로': str(file_path),
|
||||
'파일명': file_path.name,
|
||||
'파일 형식': ext,
|
||||
'변환 PDF 경로': str(output_pdf) if success else "",
|
||||
'상태': "성공" if success else "실패",
|
||||
'메시지': message,
|
||||
'처리 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
return success, message
|
||||
|
||||
def create_excel_report(self, excel_path):
|
||||
"""변환 결과를 엑셀로 저장"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "변환 결과"
|
||||
|
||||
# 헤더 스타일
|
||||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
# 헤더 작성
|
||||
headers = ['번호', '원본 경로', '파일명', '파일 형식', '변환 PDF 경로', '상태', '메시지', '처리 시간']
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
# 데이터 작성
|
||||
for idx, log in enumerate(self.conversion_log, 2):
|
||||
ws.cell(row=idx, column=1, value=idx-1)
|
||||
ws.cell(row=idx, column=2, value=log['원본 경로'])
|
||||
ws.cell(row=idx, column=3, value=log['파일명'])
|
||||
ws.cell(row=idx, column=4, value=log['파일 형식'])
|
||||
ws.cell(row=idx, column=5, value=log['변환 PDF 경로'])
|
||||
|
||||
# 상태에 따라 색상 표시
|
||||
status_cell = ws.cell(row=idx, column=6, value=log['상태'])
|
||||
if log['상태'] == "성공":
|
||||
status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
||||
status_cell.font = Font(color="006100")
|
||||
else:
|
||||
status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
|
||||
status_cell.font = Font(color="9C0006")
|
||||
|
||||
ws.cell(row=idx, column=7, value=log['메시지'])
|
||||
ws.cell(row=idx, column=8, value=log['처리 시간'])
|
||||
|
||||
# 열 너비 자동 조정
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# 요약 시트 추가
|
||||
summary_ws = wb.create_sheet(title="요약")
|
||||
|
||||
total_files = len(self.conversion_log)
|
||||
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
|
||||
fail_count = total_files - success_count
|
||||
|
||||
summary_data = [
|
||||
['항목', '값'],
|
||||
['총 파일 수', total_files],
|
||||
['변환 성공', success_count],
|
||||
['변환 실패', fail_count],
|
||||
['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"],
|
||||
['', ''],
|
||||
['원본 폴더', str(self.source_dir)],
|
||||
['출력 폴더', str(self.output_dir)],
|
||||
['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')]
|
||||
]
|
||||
|
||||
for row_idx, row_data in enumerate(summary_data, 1):
|
||||
for col_idx, value in enumerate(row_data, 1):
|
||||
cell = summary_ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
if row_idx == 1:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left')
|
||||
|
||||
summary_ws.column_dimensions['A'].width = 20
|
||||
summary_ws.column_dimensions['B'].width = 60
|
||||
|
||||
# 저장
|
||||
wb.save(excel_path)
|
||||
print(f"\n엑셀 보고서 생성 완료: {excel_path}")
|
||||
|
||||
def run(self):
|
||||
"""전체 변환 작업 실행"""
|
||||
print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"원본 폴더: {self.source_dir}")
|
||||
print(f"출력 폴더: {self.output_dir}")
|
||||
|
||||
# DWG TrueView 확인
|
||||
if self.trueview_path:
|
||||
print(f"DWG TrueView 발견: {self.trueview_path}")
|
||||
else:
|
||||
print("경고: DWG TrueView를 찾을 수 없습니다. DWG 파일 변환이 불가능합니다.")
|
||||
|
||||
print("-" * 80)
|
||||
|
||||
# 모든 파일 가져오기
|
||||
all_files = self.get_all_files()
|
||||
total_files = len(all_files)
|
||||
|
||||
# ★ 파일 분류: 동영상 vs 나머지
|
||||
video_files = []
|
||||
other_files = []
|
||||
|
||||
for file_path in all_files:
|
||||
if file_path.suffix.lower() in self.video_extensions:
|
||||
video_files.append(file_path)
|
||||
else:
|
||||
other_files.append(file_path)
|
||||
|
||||
print(f"\n총 {total_files}개 파일 발견")
|
||||
print(f" - 문서/이미지 등: {len(other_files)}개")
|
||||
print(f" - 동영상: {len(video_files)}개")
|
||||
print("\n[1단계] 문서 파일 변환 시작...\n")
|
||||
|
||||
# ★ 1단계: 문서 파일 먼저 처리
|
||||
for idx, file_path in enumerate(other_files, 1):
|
||||
print(f"[{idx}/{len(other_files)}] {file_path.name} 처리 중...", end=' ')
|
||||
success, message = self.process_file(file_path)
|
||||
print(f"{'✓' if success else '✗'} {message}")
|
||||
|
||||
# ★ 2단계: domain.txt 로드
|
||||
domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테스트 중(측량)\domain.txt
|
||||
if domain_path.exists():
|
||||
self.domain_terms = domain_path.read_text(encoding='utf-8')
|
||||
print(f"\n[2단계] 도메인 용어 사전 로드 완료: {domain_path}")
|
||||
print(f" - 용어 수: 약 {len(self.domain_terms.split())}개 단어")
|
||||
else:
|
||||
print(f"\n[2단계] 도메인 용어 사전 없음: {domain_path}")
|
||||
print(" - 기본 음성 인식으로 진행합니다.")
|
||||
|
||||
# ★ 3단계: 동영상 파일 처리
|
||||
if video_files:
|
||||
print(f"\n[3단계] 동영상 음성 인식 시작...\n")
|
||||
for idx, file_path in enumerate(video_files, 1):
|
||||
print(f"[{idx}/{len(video_files)}] {file_path.name} 처리 중...", end=' ')
|
||||
success, message = self.process_file(file_path)
|
||||
print(f"{'✓' if success else '✗'} {message}")
|
||||
|
||||
# 엑셀 보고서 생성
|
||||
excel_path = self.output_dir / f"변환_결과_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
self.create_excel_report(excel_path)
|
||||
|
||||
# 최종 요약
|
||||
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
|
||||
print("\n" + "=" * 80)
|
||||
print(f"작업 완료!")
|
||||
print(f"총 파일: {total_files}개")
|
||||
print(f"성공: {success_count}개")
|
||||
print(f"실패: {total_files - success_count}개")
|
||||
print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%")
|
||||
print("=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 경로 설정
|
||||
SOURCE_DIR = r"D:\for python\테스트 중(측량)\측량_GIS_드론 관련 자료들"
|
||||
OUTPUT_DIR = r"D:\for python\테스트 중(측량)\추출"
|
||||
|
||||
# 변환기 실행
|
||||
converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR)
|
||||
converter.run()
|
||||
789
converters/pipeline/step2_extract.py
Normal file
@@ -0,0 +1,789 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
extract_1_v2.py
|
||||
|
||||
PDF에서 텍스트(md)와 이미지(png)를 추출
|
||||
- 하위 폴더 구조 유지
|
||||
- 이미지 메타데이터 JSON 생성 (폴더경로, 파일명, 페이지, 위치, 캡션 등)
|
||||
"""
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# ===== OCR 설정 (선택적) =====
|
||||
try:
|
||||
import pytesseract
|
||||
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
TESSERACT_AVAILABLE = True
|
||||
except ImportError:
|
||||
TESSERACT_AVAILABLE = False
|
||||
print("[INFO] pytesseract 미설치 - 텍스트 잘림 필터 비활성화")
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원본 위치
|
||||
OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치
|
||||
|
||||
CAPTION_PATTERN = re.compile(
|
||||
r'^\s*(?:[<\[\(\{]\s*)?(그림|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def get_figure_rects(page):
|
||||
"""
|
||||
Identifies figure regions based on '<그림 N>' captions and vector drawings.
|
||||
Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index}
|
||||
"""
|
||||
drawings = page.get_drawings()
|
||||
|
||||
blocks = page.get_text("blocks")
|
||||
captions = []
|
||||
|
||||
for i, b in enumerate(blocks):
|
||||
text = b[4]
|
||||
if CAPTION_PATTERN.search(text):
|
||||
captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []})
|
||||
|
||||
if not captions:
|
||||
return []
|
||||
|
||||
filtered_drawings_rects = []
|
||||
for d in drawings:
|
||||
r = d["rect"]
|
||||
if r.height > page.rect.height / 3 and r.width < 5:
|
||||
continue
|
||||
if r.width > page.rect.width * 0.9:
|
||||
continue
|
||||
filtered_drawings_rects.append(r)
|
||||
|
||||
page_area = page.rect.get_area()
|
||||
img_rects = []
|
||||
for b in page.get_text("dict")["blocks"]:
|
||||
if b.get("type") == 1:
|
||||
ir = fitz.Rect(b["bbox"])
|
||||
if ir.get_area() < page_area * 0.01:
|
||||
continue
|
||||
img_rects.append(ir)
|
||||
|
||||
remaining_drawings = filtered_drawings_rects + img_rects
|
||||
caption_clusters = {cap['index']: [cap['rect']] for cap in captions}
|
||||
|
||||
def is_text_between(r1, r2, text_blocks):
|
||||
if r1.intersects(r2):
|
||||
return False
|
||||
union = r1 | r2
|
||||
for b in text_blocks:
|
||||
b_rect = fitz.Rect(b[:4])
|
||||
text_content = b[4]
|
||||
if len(text_content.strip()) < 20:
|
||||
continue
|
||||
if not b_rect.intersects(union):
|
||||
continue
|
||||
if b_rect.intersects(r1) or b_rect.intersects(r2):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
to_remove = []
|
||||
|
||||
for d_rect in remaining_drawings:
|
||||
best_cluster_key = None
|
||||
min_dist = float('inf')
|
||||
|
||||
for cap_index, cluster_rects in caption_clusters.items():
|
||||
for r in cluster_rects:
|
||||
dist = 0
|
||||
if d_rect.intersects(r):
|
||||
dist = 0
|
||||
else:
|
||||
x_dist = 0
|
||||
if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1
|
||||
elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1
|
||||
|
||||
y_dist = 0
|
||||
if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1
|
||||
elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1
|
||||
|
||||
if x_dist < 150 and y_dist < 150:
|
||||
dist = max(x_dist, y_dist) + 0.1
|
||||
else:
|
||||
dist = float('inf')
|
||||
|
||||
if dist < min_dist:
|
||||
if not is_text_between(r, d_rect, blocks):
|
||||
min_dist = dist
|
||||
best_cluster_key = cap_index
|
||||
|
||||
if min_dist == 0:
|
||||
break
|
||||
|
||||
if best_cluster_key is not None and min_dist < 150:
|
||||
caption_clusters[best_cluster_key].append(d_rect)
|
||||
to_remove.append(d_rect)
|
||||
changed = True
|
||||
|
||||
for r in to_remove:
|
||||
remaining_drawings.remove(r)
|
||||
|
||||
figure_regions = []
|
||||
|
||||
for cap in captions:
|
||||
cluster_rects = caption_clusters[cap['index']]
|
||||
content_rects = cluster_rects[1:]
|
||||
|
||||
if not content_rects:
|
||||
continue
|
||||
|
||||
union_rect = content_rects[0]
|
||||
for r in content_rects[1:]:
|
||||
union_rect = union_rect | r
|
||||
|
||||
union_rect.x0 = max(0, union_rect.x0 - 5)
|
||||
union_rect.x1 = min(page.rect.width, union_rect.x1 + 5)
|
||||
union_rect.y0 = max(0, union_rect.y0 - 5)
|
||||
union_rect.y1 = min(page.rect.height, union_rect.y1 + 5)
|
||||
|
||||
cap_rect = cap['rect']
|
||||
|
||||
if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2:
|
||||
if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2
|
||||
else:
|
||||
if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2
|
||||
|
||||
area = union_rect.get_area()
|
||||
page_area = page.rect.get_area()
|
||||
|
||||
if area < page_area * 0.01:
|
||||
continue
|
||||
|
||||
if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6:
|
||||
continue
|
||||
if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6:
|
||||
continue
|
||||
|
||||
text_blocks = page.get_text("blocks")
|
||||
text_count = 0
|
||||
|
||||
for b in text_blocks:
|
||||
b_rect = fitz.Rect(b[:4])
|
||||
if not b_rect.intersects(union_rect):
|
||||
continue
|
||||
text = b[4].strip()
|
||||
if len(text) < 5:
|
||||
continue
|
||||
text_count += 1
|
||||
|
||||
if text_count < 0:
|
||||
continue
|
||||
|
||||
figure_regions.append({
|
||||
'rect': union_rect,
|
||||
'caption_index': cap['index'],
|
||||
'caption_rect': cap['rect'],
|
||||
'caption_text': cap['text'].strip() # ★ 캡션 텍스트 저장
|
||||
})
|
||||
|
||||
return figure_regions
|
||||
|
||||
|
||||
def pixmap_metrics(pix):
|
||||
arr = np.frombuffer(pix.samples, dtype=np.uint8)
|
||||
c = 4 if pix.alpha else 3
|
||||
arr = arr.reshape(pix.height, pix.width, c)[:, :, :3]
|
||||
gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8)
|
||||
white = gray > 245
|
||||
nonwhite_ratio = float(1.0 - white.mean())
|
||||
gx = np.abs(np.diff(gray.astype(np.int16), axis=1))
|
||||
gy = np.abs(np.diff(gray.astype(np.int16), axis=0))
|
||||
edge = (gx[:-1, :] + gy[:, :-1]) > 40
|
||||
edge_ratio = float(edge.mean())
|
||||
var = float(gray.var())
|
||||
return nonwhite_ratio, edge_ratio, var
|
||||
|
||||
|
||||
def keep_figure(pix):
|
||||
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
|
||||
if nonwhite_ratio < 0.004:
|
||||
return False, nonwhite_ratio, edge_ratio, var
|
||||
if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20:
|
||||
return False, nonwhite_ratio, edge_ratio, var
|
||||
return True, nonwhite_ratio, edge_ratio, var
|
||||
|
||||
|
||||
# ===== 추가 이미지 필터 함수들 (v2.1) =====
|
||||
|
||||
def pix_to_pil(pix):
|
||||
"""PyMuPDF Pixmap을 PIL Image로 변환"""
|
||||
img_data = pix.tobytes("png")
|
||||
return Image.open(io.BytesIO(img_data))
|
||||
|
||||
|
||||
def has_cut_text_at_boundary(pix, margin=5):
|
||||
"""
|
||||
이미지 경계에서 텍스트가 잘렸는지 감지
|
||||
- 이미지 테두리 근처에 텍스트 박스가 있으면 잘린 것으로 판단
|
||||
|
||||
Args:
|
||||
pix: PyMuPDF Pixmap
|
||||
margin: 경계로부터의 여유 픽셀 (기본 5px)
|
||||
|
||||
Returns:
|
||||
bool: 텍스트가 잘렸으면 True
|
||||
"""
|
||||
if not TESSERACT_AVAILABLE:
|
||||
return False # OCR 없으면 필터 비활성화
|
||||
|
||||
try:
|
||||
img = pix_to_pil(pix)
|
||||
width, height = img.size
|
||||
|
||||
# OCR로 텍스트 위치 추출
|
||||
data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT)
|
||||
|
||||
for i, text in enumerate(data['text']):
|
||||
text = str(text).strip()
|
||||
if len(text) < 2: # 너무 짧은 텍스트는 무시
|
||||
continue
|
||||
|
||||
x = data['left'][i]
|
||||
y = data['top'][i]
|
||||
w = data['width'][i]
|
||||
h = data['height'][i]
|
||||
|
||||
# 텍스트가 이미지 경계에 너무 가까우면 = 잘린 것
|
||||
# 왼쪽 경계
|
||||
if x <= margin:
|
||||
return True
|
||||
# 오른쪽 경계
|
||||
if x + w >= width - margin:
|
||||
return True
|
||||
# 상단 경계 (헤더 제외를 위해 좀 더 여유)
|
||||
if y <= margin and h < height * 0.3:
|
||||
return True
|
||||
# 하단 경계
|
||||
if y + h >= height - margin:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
# OCR 실패 시 필터 통과 (이미지 유지)
|
||||
return False
|
||||
|
||||
|
||||
def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500):
|
||||
"""
|
||||
배경 패턴 + 텍스트만 있는 장식용 이미지인지 감지
|
||||
- 엣지가 적고 (복잡한 도표/사진이 아님)
|
||||
- 색상 다양성이 낮으면 (단순 그라데이션 배경)
|
||||
|
||||
Args:
|
||||
pix: PyMuPDF Pixmap
|
||||
edge_threshold: 엣지 비율 임계값 (기본 0.02 = 2%)
|
||||
color_var_threshold: 색상 분산 임계값
|
||||
|
||||
Returns:
|
||||
bool: 장식용 배경이면 True
|
||||
"""
|
||||
try:
|
||||
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
|
||||
|
||||
# 엣지가 거의 없고 (단순한 이미지)
|
||||
# 색상 분산도 낮으면 (배경 패턴)
|
||||
if edge_ratio < edge_threshold and var < color_var_threshold:
|
||||
# 추가 확인: 텍스트만 있는지 OCR로 체크
|
||||
if TESSERACT_AVAILABLE:
|
||||
try:
|
||||
img = pix_to_pil(pix)
|
||||
text = pytesseract.image_to_string(img, lang='kor+eng').strip()
|
||||
|
||||
# 텍스트가 있고, 이미지가 단순하면 = 텍스트 배경
|
||||
if len(text) > 3 and edge_ratio < 0.015:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_header_footer_region(rect, page_rect, height_threshold=0.12):
|
||||
"""
|
||||
헤더/푸터 영역에 있는 이미지인지 감지
|
||||
- 페이지 상단 12% 또는 하단 12%에 위치
|
||||
- 높이가 낮은 strip 형태
|
||||
|
||||
Args:
|
||||
rect: 이미지 영역 (fitz.Rect)
|
||||
page_rect: 페이지 전체 영역 (fitz.Rect)
|
||||
height_threshold: 헤더/푸터 영역 비율 (기본 12%)
|
||||
|
||||
Returns:
|
||||
bool: 헤더/푸터 영역이면 True
|
||||
"""
|
||||
page_height = page_rect.height
|
||||
img_height = rect.height
|
||||
|
||||
# 상단 영역 체크
|
||||
if rect.y0 < page_height * height_threshold:
|
||||
# 높이가 페이지의 15% 미만인 strip이면 헤더
|
||||
if img_height < page_height * 0.15:
|
||||
return True
|
||||
|
||||
# 하단 영역 체크
|
||||
if rect.y1 > page_height * (1 - height_threshold):
|
||||
# 높이가 페이지의 15% 미만인 strip이면 푸터
|
||||
if img_height < page_height * 0.15:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def should_filter_image(pix, rect, page_rect):
|
||||
"""
|
||||
이미지를 필터링해야 하는지 종합 판단
|
||||
|
||||
Args:
|
||||
pix: PyMuPDF Pixmap
|
||||
rect: 이미지 영역
|
||||
page_rect: 페이지 전체 영역
|
||||
|
||||
Returns:
|
||||
tuple: (필터링 여부, 필터링 사유)
|
||||
"""
|
||||
# 1. 헤더/푸터 영역 체크
|
||||
if is_header_footer_region(rect, page_rect):
|
||||
return True, "header_footer"
|
||||
|
||||
# 2. 텍스트 잘림 체크
|
||||
if has_cut_text_at_boundary(pix):
|
||||
return True, "cut_text"
|
||||
|
||||
# 3. 장식용 배경 체크
|
||||
if is_decorative_background(pix):
|
||||
return True, "decorative_background"
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata):
|
||||
"""
|
||||
PDF 내용 추출
|
||||
|
||||
Args:
|
||||
pdf_path: PDF 파일 경로
|
||||
output_md_path: 출력 MD 파일 경로
|
||||
img_dir: 이미지 저장 폴더
|
||||
metadata: 메타데이터 딕셔너리 (폴더 경로, 파일명 등)
|
||||
|
||||
Returns:
|
||||
image_metadata_list: 추출된 이미지들의 메타데이터 리스트
|
||||
"""
|
||||
os.makedirs(img_dir, exist_ok=True)
|
||||
|
||||
image_metadata_list = [] # ★ 이미지 메타데이터 수집
|
||||
|
||||
doc = fitz.open(pdf_path)
|
||||
total_pages = len(doc)
|
||||
|
||||
with open(output_md_path, "w", encoding="utf-8") as md_file:
|
||||
# ★ 메타데이터 헤더 추가
|
||||
md_file.write(f"---\n")
|
||||
md_file.write(f"source_pdf: {metadata['pdf_name']}\n")
|
||||
md_file.write(f"source_folder: {metadata['relative_folder']}\n")
|
||||
md_file.write(f"total_pages: {total_pages}\n")
|
||||
md_file.write(f"extracted_at: {datetime.now().isoformat()}\n")
|
||||
md_file.write(f"---\n\n")
|
||||
md_file.write(f"# {metadata['pdf_name']}\n\n")
|
||||
|
||||
for page_num, page in enumerate(doc):
|
||||
md_file.write(f"\n## Page {page_num + 1}\n\n")
|
||||
img_rel_dir = os.path.basename(img_dir)
|
||||
|
||||
figure_regions = get_figure_rects(page)
|
||||
|
||||
kept_figures = []
|
||||
for i, fig in enumerate(figure_regions):
|
||||
rect = fig['rect']
|
||||
pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB)
|
||||
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
|
||||
if not ok:
|
||||
continue
|
||||
|
||||
pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB)
|
||||
|
||||
# ★ 추가 필터 적용 (v2.1)
|
||||
should_filter, filter_reason = should_filter_image(pix, rect, page.rect)
|
||||
if should_filter:
|
||||
continue
|
||||
|
||||
img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png"
|
||||
img_path = os.path.join(img_dir, img_name)
|
||||
pix.save(img_path)
|
||||
|
||||
fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/")
|
||||
fig['img_name'] = img_name
|
||||
kept_figures.append(fig)
|
||||
|
||||
# ★ 이미지 메타데이터 수집
|
||||
image_metadata_list.append({
|
||||
"image_file": img_name,
|
||||
"image_path": str(Path(img_dir) / img_name),
|
||||
"type": "figure",
|
||||
"source_pdf": metadata['pdf_name'],
|
||||
"source_folder": metadata['relative_folder'],
|
||||
"full_path": metadata['full_path'],
|
||||
"page": page_num + 1,
|
||||
"total_pages": total_pages,
|
||||
"caption": fig.get('caption_text', ''),
|
||||
"rect": {
|
||||
"x0": round(rect.x0, 2),
|
||||
"y0": round(rect.y0, 2),
|
||||
"x1": round(rect.x1, 2),
|
||||
"y1": round(rect.y1, 2)
|
||||
}
|
||||
})
|
||||
|
||||
figure_regions = kept_figures
|
||||
|
||||
caption_present = any(
|
||||
CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks")
|
||||
)
|
||||
uncaptioned_idx = 0
|
||||
|
||||
items = []
|
||||
|
||||
def inside_any_figure(block_rect, figures):
|
||||
for fig in figures:
|
||||
intersect = block_rect & fig["rect"]
|
||||
if intersect.get_area() > 0.5 * block_rect.get_area():
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_full_width_rect(r, page_rect):
|
||||
return r.width >= page_rect.width * 0.78
|
||||
|
||||
def figure_anchor_rect(fig, page_rect):
|
||||
cap = fig["caption_rect"]
|
||||
rect = fig["rect"]
|
||||
if cap.y0 >= rect.y0:
|
||||
y = max(0.0, cap.y0 - 0.02)
|
||||
else:
|
||||
y = min(page_rect.height - 0.02, cap.y1 + 0.02)
|
||||
return fitz.Rect(cap.x0, y, cap.x1, y + 0.02)
|
||||
|
||||
for fig in figure_regions:
|
||||
anchor = figure_anchor_rect(fig, page.rect)
|
||||
md = (
|
||||
f"\n\n"
|
||||
f"*{fig.get('caption_text', '')}*\n\n"
|
||||
)
|
||||
items.append({
|
||||
"kind": "figure",
|
||||
"rect": anchor,
|
||||
"kind_order": 0,
|
||||
"md": md,
|
||||
})
|
||||
|
||||
raw_blocks = page.get_text("dict")["blocks"]
|
||||
|
||||
for block in raw_blocks:
|
||||
block_rect = fitz.Rect(block["bbox"])
|
||||
|
||||
if block.get("type") == 0:
|
||||
if inside_any_figure(block_rect, figure_regions):
|
||||
continue
|
||||
items.append({
|
||||
"kind": "text",
|
||||
"rect": block_rect,
|
||||
"kind_order": 2,
|
||||
"block": block,
|
||||
})
|
||||
continue
|
||||
|
||||
if block.get("type") == 1:
|
||||
if inside_any_figure(block_rect, figure_regions):
|
||||
continue
|
||||
if caption_present:
|
||||
continue
|
||||
|
||||
page_area = page.rect.get_area()
|
||||
if block_rect.get_area() < page_area * 0.005:
|
||||
continue
|
||||
|
||||
ratio = block_rect.width / max(1.0, block_rect.height)
|
||||
if ratio < 0.25 or ratio > 4.0:
|
||||
continue
|
||||
|
||||
pix_preview = page.get_pixmap(
|
||||
clip=block_rect, dpi=80, colorspace=fitz.csRGB
|
||||
)
|
||||
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
|
||||
if not ok:
|
||||
continue
|
||||
|
||||
pix = page.get_pixmap(
|
||||
clip=block_rect, dpi=150, colorspace=fitz.csRGB
|
||||
)
|
||||
|
||||
# ★ 추가 필터 적용 (v2.1)
|
||||
should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect)
|
||||
if should_filter:
|
||||
continue
|
||||
|
||||
img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png"
|
||||
img_path = os.path.join(img_dir, img_name)
|
||||
pix.save(img_path)
|
||||
|
||||
rel = os.path.join(img_rel_dir, img_name).replace("\\", "/")
|
||||
r = block_rect
|
||||
md = (
|
||||
f'\n\n'
|
||||
f'*Page {page_num + 1} Photo*\n\n'
|
||||
)
|
||||
|
||||
items.append({
|
||||
"kind": "raster",
|
||||
"rect": block_rect,
|
||||
"kind_order": 1,
|
||||
"md": md,
|
||||
})
|
||||
|
||||
# ★ 캡션 없는 이미지 메타데이터
|
||||
image_metadata_list.append({
|
||||
"image_file": img_name,
|
||||
"image_path": str(Path(img_dir) / img_name),
|
||||
"type": "photo",
|
||||
"source_pdf": metadata['pdf_name'],
|
||||
"source_folder": metadata['relative_folder'],
|
||||
"full_path": metadata['full_path'],
|
||||
"page": page_num + 1,
|
||||
"total_pages": total_pages,
|
||||
"caption": "",
|
||||
"rect": {
|
||||
"x0": round(r.x0, 2),
|
||||
"y0": round(r.y0, 2),
|
||||
"x1": round(r.x1, 2),
|
||||
"y1": round(r.y1, 2)
|
||||
}
|
||||
})
|
||||
|
||||
uncaptioned_idx += 1
|
||||
continue
|
||||
|
||||
# 읽기 순서 정렬
|
||||
text_items = [it for it in items if it["kind"] == "text"]
|
||||
page_w = page.rect.width
|
||||
mid = page_w / 2.0
|
||||
|
||||
candidates = []
|
||||
for it in text_items:
|
||||
r = it["rect"]
|
||||
if is_full_width_rect(r, page.rect):
|
||||
continue
|
||||
if r.width < page_w * 0.2:
|
||||
continue
|
||||
candidates.append(it)
|
||||
|
||||
left = [it for it in candidates if it["rect"].x0 < mid * 0.95]
|
||||
right = [it for it in candidates if it["rect"].x0 > mid * 1.05]
|
||||
two_cols = len(left) >= 3 and len(right) >= 3
|
||||
|
||||
col_y0 = None
|
||||
col_y1 = None
|
||||
seps = []
|
||||
|
||||
if two_cols and left and right:
|
||||
col_y0 = min(
|
||||
min(it["rect"].y0 for it in left),
|
||||
min(it["rect"].y0 for it in right),
|
||||
)
|
||||
col_y1 = max(
|
||||
max(it["rect"].y1 for it in left),
|
||||
max(it["rect"].y1 for it in right),
|
||||
)
|
||||
for it in text_items:
|
||||
r = it["rect"]
|
||||
if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect):
|
||||
seps.append(r.y0)
|
||||
seps = sorted(set(seps))
|
||||
|
||||
def seg_index(y0, separators):
|
||||
if not separators:
|
||||
return 0
|
||||
n = 0
|
||||
for s in separators:
|
||||
if y0 >= s:
|
||||
n += 1
|
||||
else:
|
||||
break
|
||||
return n
|
||||
|
||||
def order_key(it):
|
||||
r = it["rect"]
|
||||
if not two_cols:
|
||||
return (r.y0, r.x0, it["kind_order"])
|
||||
if col_y0 is not None and r.y1 <= col_y0:
|
||||
return (0, r.y0, r.x0, it["kind_order"])
|
||||
if col_y1 is not None and r.y0 >= col_y1:
|
||||
return (2, r.y0, r.x0, it["kind_order"])
|
||||
seg = seg_index(r.y0, seps)
|
||||
if is_full_width_rect(r, page.rect):
|
||||
col = 2
|
||||
else:
|
||||
col = 0 if r.x0 < mid else 1
|
||||
return (1, seg, col, r.y0, r.x0, it["kind_order"])
|
||||
|
||||
items.sort(key=order_key)
|
||||
|
||||
for it in items:
|
||||
if it["kind"] in ("figure", "raster"):
|
||||
md_file.write(it["md"])
|
||||
continue
|
||||
|
||||
block = it["block"]
|
||||
for line in block.get("lines", []):
|
||||
for span in line.get("spans", []):
|
||||
md_file.write(span.get("text", "") + " ")
|
||||
md_file.write("\n")
|
||||
md_file.write("\n")
|
||||
|
||||
doc.close()
|
||||
return image_metadata_list
|
||||
|
||||
|
||||
def process_all_pdfs():
|
||||
"""
|
||||
BASE_DIR 하위의 모든 PDF를 재귀적으로 처리
|
||||
폴더 구조를 유지하면서 OUTPUT_BASE에 저장
|
||||
"""
|
||||
# 출력 폴더 생성
|
||||
OUTPUT_BASE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 전체 이미지 메타데이터 수집
|
||||
all_image_metadata = []
|
||||
|
||||
# 처리 통계
|
||||
stats = {
|
||||
"total_pdfs": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"total_images": 0
|
||||
}
|
||||
|
||||
# 실패 로그
|
||||
failed_files = []
|
||||
|
||||
print(f"=" * 60)
|
||||
print(f"PDF 추출 시작")
|
||||
print(f"원본 폴더: {BASE_DIR}")
|
||||
print(f"출력 폴더: {OUTPUT_BASE}")
|
||||
print(f"=" * 60)
|
||||
|
||||
# 모든 PDF 파일 찾기
|
||||
pdf_files = list(BASE_DIR.rglob("*.pdf"))
|
||||
stats["total_pdfs"] = len(pdf_files)
|
||||
|
||||
print(f"\n총 {len(pdf_files)}개 PDF 발견\n")
|
||||
|
||||
for idx, pdf_path in enumerate(pdf_files, 1):
|
||||
try:
|
||||
# 상대 경로 계산
|
||||
relative_path = pdf_path.relative_to(BASE_DIR)
|
||||
relative_folder = str(relative_path.parent)
|
||||
if relative_folder == ".":
|
||||
relative_folder = ""
|
||||
|
||||
pdf_name = pdf_path.name
|
||||
pdf_stem = pdf_path.stem
|
||||
|
||||
# 출력 경로 설정 (폴더 구조 유지)
|
||||
output_folder = OUTPUT_BASE / relative_path.parent
|
||||
output_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_md = output_folder / f"{pdf_stem}.md"
|
||||
img_folder = output_folder / f"{pdf_stem}_img"
|
||||
|
||||
# 메타데이터 준비
|
||||
metadata = {
|
||||
"pdf_name": pdf_name,
|
||||
"pdf_stem": pdf_stem,
|
||||
"relative_folder": relative_folder,
|
||||
"full_path": str(relative_path),
|
||||
}
|
||||
|
||||
print(f"[{idx}/{len(pdf_files)}] {relative_path}")
|
||||
|
||||
# PDF 처리
|
||||
image_metas = extract_pdf_content(
|
||||
str(pdf_path),
|
||||
str(output_md),
|
||||
str(img_folder),
|
||||
metadata
|
||||
)
|
||||
|
||||
all_image_metadata.extend(image_metas)
|
||||
stats["success"] += 1
|
||||
stats["total_images"] += len(image_metas)
|
||||
|
||||
print(f" ✓ 완료 (이미지 {len(image_metas)}개)")
|
||||
|
||||
except Exception as e:
|
||||
stats["failed"] += 1
|
||||
failed_files.append({
|
||||
"file": str(pdf_path),
|
||||
"error": str(e)
|
||||
})
|
||||
print(f" ✗ 실패: {e}")
|
||||
|
||||
# 전체 이미지 메타데이터 저장
|
||||
meta_output_path = OUTPUT_BASE / "image_metadata.json"
|
||||
with open(meta_output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(all_image_metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 처리 요약 저장
|
||||
summary = {
|
||||
"processed_at": datetime.now().isoformat(),
|
||||
"source_dir": str(BASE_DIR),
|
||||
"output_dir": str(OUTPUT_BASE),
|
||||
"statistics": stats,
|
||||
"failed_files": failed_files
|
||||
}
|
||||
|
||||
summary_path = OUTPUT_BASE / "extraction_summary.json"
|
||||
with open(summary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 결과 출력
|
||||
print(f"\n" + "=" * 60)
|
||||
print(f"추출 완료!")
|
||||
print(f"=" * 60)
|
||||
print(f"총 PDF: {stats['total_pdfs']}개")
|
||||
print(f"성공: {stats['success']}개")
|
||||
print(f"실패: {stats['failed']}개")
|
||||
print(f"추출된 이미지: {stats['total_images']}개")
|
||||
print(f"\n이미지 메타데이터: {meta_output_path}")
|
||||
print(f"처리 요약: {summary_path}")
|
||||
|
||||
if failed_files:
|
||||
print(f"\n실패한 파일:")
|
||||
for f in failed_files:
|
||||
print(f" - {f['file']}: {f['error']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
process_all_pdfs()
|
||||
265
converters/pipeline/step3_domain.py
Normal file
@@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
domain_prompt.py
|
||||
|
||||
기능:
|
||||
- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파일들의
|
||||
파일명과 내용 일부를 샘플링한다.
|
||||
- 이 샘플을 기반으로, 문서 묶음의 분야/업무 맥락을 파악하고
|
||||
"너는 ~~ 분야의 전문가이다. 나는 ~~를 하고 싶다..." 형식의
|
||||
도메인 전용 시스템 프롬프트를 자동 생성한다.
|
||||
- 결과는 output/context/domain_prompt.txt 로 저장된다.
|
||||
|
||||
이 domain_prompt.txt 내용은 이후 모든 GPT 호출(system role)에 공통으로 붙여 사용할 수 있다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
import pandas as pd
|
||||
from openai import OpenAI
|
||||
import pytesseract
|
||||
from api_config import API_KEYS
|
||||
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\survey_test\extract")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 (구조만 유지, 키는 마스터가 직접 입력) =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== OCR 설정 =====
|
||||
OCR_LANG = "kor+eng"
|
||||
|
||||
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"}
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
|
||||
def safe_rel(p: Path) -> str:
|
||||
try:
|
||||
return str(p.relative_to(DATA_ROOT))
|
||||
except Exception:
|
||||
return str(p)
|
||||
|
||||
|
||||
def ocr_image(img_path: Path) -> str:
|
||||
try:
|
||||
return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip()
|
||||
except Exception as e:
|
||||
log(f"[WARN] OCR 실패: {safe_rel(img_path)} | {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def sample_from_pdf(p: Path, max_chars: int = 1000) -> str:
|
||||
texts = []
|
||||
try:
|
||||
with pdfplumber.open(str(p)) as pdf:
|
||||
# 앞쪽 몇 페이지만 샘플링
|
||||
for page in pdf.pages[:3]:
|
||||
t = page.extract_text() or ""
|
||||
if t:
|
||||
texts.append(t)
|
||||
if sum(len(x) for x in texts) >= max_chars:
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"[WARN] PDF 샘플 추출 실패: {safe_rel(p)} | {e}")
|
||||
joined = "\n".join(texts)
|
||||
return joined[:max_chars]
|
||||
|
||||
|
||||
def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str:
|
||||
texts = [f"[파일명] {p.name}"]
|
||||
try:
|
||||
xls = pd.ExcelFile(str(p))
|
||||
for sheet_name in xls.sheet_names[:3]:
|
||||
try:
|
||||
df = xls.parse(sheet_name)
|
||||
except Exception as e:
|
||||
log(f"[WARN] 시트 로딩 실패: {safe_rel(p)} | {sheet_name} | {e}")
|
||||
continue
|
||||
texts.append(f"\n[시트] {sheet_name}")
|
||||
texts.append("컬럼: " + ", ".join(map(str, df.columns)))
|
||||
head = df.head(5)
|
||||
texts.append(head.to_string(index=False))
|
||||
if sum(len(x) for x in texts) >= max_chars:
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"[WARN] XLSX 샘플 추출 실패: {safe_rel(p)} | {e}")
|
||||
joined = "\n".join(texts)
|
||||
return joined[:max_chars]
|
||||
|
||||
|
||||
def sample_from_text_file(p: Path, max_chars: int = 1000) -> str:
|
||||
try:
|
||||
t = p.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
t = p.read_text(encoding="cp949", errors="ignore")
|
||||
return t[:max_chars]
|
||||
|
||||
|
||||
def gather_file_samples(
|
||||
max_files_per_type: int = 100,
|
||||
max_total_samples: int = 300,
|
||||
max_chars_per_sample: int = 1000,
|
||||
):
|
||||
|
||||
file_names = []
|
||||
samples = []
|
||||
|
||||
count_pdf = 0
|
||||
count_xlsx = 0
|
||||
count_img = 0
|
||||
count_txt = 0
|
||||
|
||||
for root, dirs, files in os.walk(DATA_ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
|
||||
cur_dir = Path(root)
|
||||
|
||||
for fname in files:
|
||||
fpath = cur_dir / fname
|
||||
ext = fpath.suffix.lower()
|
||||
|
||||
# 파일명은 전체 다 모으되, 샘플 추출은 제한
|
||||
file_names.append(safe_rel(fpath))
|
||||
|
||||
if len(samples) >= max_total_samples:
|
||||
continue
|
||||
|
||||
try:
|
||||
if ext == ".pdf" and count_pdf < max_files_per_type:
|
||||
s = sample_from_pdf(fpath, max_chars=max_chars_per_sample)
|
||||
if s.strip():
|
||||
samples.append(f"[PDF] {safe_rel(fpath)}\n{s}")
|
||||
count_pdf += 1
|
||||
continue
|
||||
|
||||
if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type:
|
||||
s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample)
|
||||
if s.strip():
|
||||
samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}")
|
||||
count_xlsx += 1
|
||||
continue
|
||||
|
||||
if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type:
|
||||
s = ocr_image(fpath)
|
||||
if s.strip():
|
||||
samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}")
|
||||
count_img += 1
|
||||
continue
|
||||
|
||||
if ext in {".txt", ".md"} and count_txt < max_files_per_type:
|
||||
s = sample_from_text_file(fpath, max_chars=max_chars_per_sample)
|
||||
if s.strip():
|
||||
samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}")
|
||||
count_txt += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
log(f"[WARN] 샘플 추출 실패: {safe_rel(fpath)} | {e}")
|
||||
continue
|
||||
|
||||
return file_names, samples
|
||||
|
||||
|
||||
def build_domain_prompt():
|
||||
"""
|
||||
파일명 + 내용 샘플을 GPT에게 넘겨
|
||||
'너는 ~~ 분야의 전문가이다...' 형태의 시스템 프롬프트를 생성한다.
|
||||
"""
|
||||
log("도메인 프롬프트 생성을 위한 샘플 수집 중...")
|
||||
file_names, samples = gather_file_samples()
|
||||
|
||||
if not file_names and not samples:
|
||||
log("파일 샘플이 없어 도메인 프롬프트를 생성할 수 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
file_names_text = "\n".join(file_names[:80])
|
||||
sample_text = "\n\n".join(samples[:30])
|
||||
|
||||
prompt = f"""
|
||||
다음은 한 기업의 '이슈 리포트 및 시스템 관련 자료'로 추정되는 파일들의 목록과,
|
||||
각 파일에서 일부 추출한 내용 샘플이다.
|
||||
|
||||
[파일명 목록]
|
||||
{file_names_text}
|
||||
|
||||
[내용 샘플]
|
||||
{sample_text}
|
||||
|
||||
위 자료를 바탕으로 다음을 수행하라.
|
||||
|
||||
1) 이 문서 묶음이 어떤 산업, 업무, 분야에 대한 것인지,
|
||||
핵심 키워드를 포함해 2~3줄 정도로 설명하라.
|
||||
|
||||
2) 이후, 이 문서들을 다루는 AI에게 사용할 "프롬프트 머리말"을 작성하라.
|
||||
이 머리말은 모든 후속 프롬프트 앞에 항상 붙일 예정이며,
|
||||
다음 조건을 만족해야 한다.
|
||||
|
||||
- 첫 문단: "너는 ~~ 분야의 전문가이다." 형식으로, 이 문서 묶음의 분야와 역할을 정의한다.
|
||||
- 두 번째 문단 이후: "나는 ~~을 하고 싶다.", "우리는 ~~ 의 문제를 분석하고 개선방안을 찾고자 한다." 등
|
||||
사용자가 AI에게 요구하는 전반적 목적과 관점을 정리한다.
|
||||
- 총 5~7줄 정도의 한국어 문장으로 작성한다.
|
||||
- 이후에 붙을 프롬프트(청킹, 요약, RAG, 보고서 작성 등)와 자연스럽게 연결될 수 있도록,
|
||||
역할(role), 목적, 기준(추측 금지, 사실 기반, 근거 명시 등)을 모두 포함한다.
|
||||
|
||||
출력 형식:
|
||||
- 설명과 머리말을 한 번에 출력하되,
|
||||
별도의 마크다운 없이 순수 텍스트로만 작성하라.
|
||||
- 이 출력 전체를 domain_prompt.txt에 그대로 저장할 것이다.
|
||||
"""
|
||||
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "너는 문서 묶음의 분야를 식별하고, 그에 맞는 AI 시스템 프롬프트와 컨텍스트를 설계하는 컨설턴트이다."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
content = (resp.choices[0].message.content or "").strip()
|
||||
out_path = CONTEXT_DIR / "domain_prompt.txt"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
|
||||
log(f"도메인 프롬프트 생성 완료: {out_path}")
|
||||
return content
|
||||
|
||||
|
||||
def main():
|
||||
log("=== 도메인 프롬프트 생성 시작 ===")
|
||||
out_path = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if out_path.exists():
|
||||
log(f"이미 domain_prompt.txt가 존재합니다: {out_path}")
|
||||
log("기존 파일을 사용하려면 종료하고, 재생성이 필요하면 파일을 삭제한 뒤 다시 실행하십시오.")
|
||||
else:
|
||||
build_domain_prompt()
|
||||
log("=== 도메인 프롬프트 작업 종료 ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
357
converters/pipeline/step4_chunk.py
Normal file
@@ -0,0 +1,357 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
chunk_and_summary_v2.py
|
||||
|
||||
기능:
|
||||
- 정리중 폴더 아래의 .md 파일들을 대상으로
|
||||
1) domain_prompt.txt 기반 GPT 의미 청킹
|
||||
2) 청크별 요약 생성
|
||||
3) 청크 내 이미지 참조 보존
|
||||
4) JSON 저장 (원문+청크+요약+이미지)
|
||||
5) RAG용 *_chunks.json 저장
|
||||
|
||||
전제:
|
||||
- extract_1_v2.py 실행 후 .md 파일들이 존재할 것
|
||||
- step1_domainprompt.py 실행 후 domain_prompt.txt가 존재할 것
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 =====
|
||||
DATA_ROOT = Path(r"D:\for python\survey_test\process")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
|
||||
|
||||
TEXT_DIR = OUTPUT_ROOT / "text"
|
||||
JSON_DIR = OUTPUT_ROOT / "json"
|
||||
RAG_DIR = OUTPUT_ROOT / "rag"
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== 스킵할 폴더 =====
|
||||
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"}
|
||||
|
||||
# ===== 이미지 참조 패턴 =====
|
||||
IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
|
||||
|
||||
|
||||
def load_domain_prompt() -> str:
|
||||
p = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if not p.exists():
|
||||
log(f"domain_prompt.txt가 없습니다: {p}")
|
||||
log("먼저 step1_domainprompt.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
def safe_rel(p: Path) -> str:
|
||||
"""DATA_ROOT 기준 상대 경로 반환"""
|
||||
try:
|
||||
return str(p.relative_to(DATA_ROOT))
|
||||
except Exception:
|
||||
return str(p)
|
||||
|
||||
|
||||
def extract_text_md(p: Path) -> str:
|
||||
"""마크다운 파일 텍스트 읽기"""
|
||||
try:
|
||||
return p.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return p.read_text(encoding="cp949", errors="ignore")
|
||||
|
||||
|
||||
def find_images_in_text(text: str) -> list:
|
||||
"""텍스트에서 이미지 참조 찾기"""
|
||||
matches = IMAGE_PATTERN.findall(text)
|
||||
return [{"alt": m[0], "path": m[1]} for m in matches]
|
||||
|
||||
|
||||
def semantic_chunk(domain_prompt: str, text: str, source_name: str):
|
||||
"""GPT 기반 의미 청킹"""
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
# 텍스트가 너무 짧으면 그냥 하나의 청크로
|
||||
if len(text) < 500:
|
||||
return [{
|
||||
"title": "전체 내용",
|
||||
"keywords": "",
|
||||
"content": text
|
||||
}]
|
||||
|
||||
user_prompt = f"""
|
||||
아래 문서를 의미 단위(문단/항목/섹션 등)로 분리하고,
|
||||
각 청크는 title / keywords / content 를 포함한 JSON 배열로 출력하라.
|
||||
|
||||
규칙:
|
||||
1. 추측 금지, 문서 내용 기반으로만 분리
|
||||
2. 이미지 참조()는 관련 텍스트와 같은 청크에 포함
|
||||
3. 각 청크는 최소 100자 이상
|
||||
4. keywords는 쉼표로 구분된 핵심 키워드 3~5개
|
||||
|
||||
문서:
|
||||
{text[:12000]}
|
||||
|
||||
JSON 배열만 출력하라. 다른 설명 없이.
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 의미 기반 청킹 전문가이다. JSON 배열만 출력한다."},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
)
|
||||
data = resp.choices[0].message.content.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
# ```json ... ``` 형식 처리
|
||||
if "```json" in data:
|
||||
data = data.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in data:
|
||||
data = data.split("```")[1].split("```")[0].strip()
|
||||
|
||||
if data.startswith("["):
|
||||
return json.loads(data)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
log(f"[WARN] JSON 파싱 실패 ({source_name}): {e}")
|
||||
except Exception as e:
|
||||
log(f"[WARN] semantic_chunk API 실패 ({source_name}): {e}")
|
||||
|
||||
# fallback: 페이지/섹션 기반 분리
|
||||
log(f"[INFO] Fallback 청킹 적용: {source_name}")
|
||||
return fallback_chunk(text)
|
||||
|
||||
|
||||
def fallback_chunk(text: str) -> list:
|
||||
"""GPT 실패 시 대체 청킹 (페이지/섹션 기반)"""
|
||||
chunks = []
|
||||
|
||||
# 페이지 구분자로 분리 시도
|
||||
if "## Page " in text:
|
||||
pages = re.split(r'\n## Page \d+\n', text)
|
||||
for i, page_content in enumerate(pages):
|
||||
if page_content.strip():
|
||||
chunks.append({
|
||||
"title": f"Page {i+1}",
|
||||
"keywords": "",
|
||||
"content": page_content.strip()
|
||||
})
|
||||
else:
|
||||
# 빈 줄 2개 이상으로 분리
|
||||
sections = re.split(r'\n{3,}', text)
|
||||
for i, section in enumerate(sections):
|
||||
if section.strip() and len(section.strip()) > 50:
|
||||
chunks.append({
|
||||
"title": f"섹션 {i+1}",
|
||||
"keywords": "",
|
||||
"content": section.strip()
|
||||
})
|
||||
|
||||
# 청크가 없으면 전체를 하나로
|
||||
if not chunks:
|
||||
chunks.append({
|
||||
"title": "전체 내용",
|
||||
"keywords": "",
|
||||
"content": text.strip()
|
||||
})
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str:
|
||||
"""청크 요약 생성"""
|
||||
if not text.strip():
|
||||
return ""
|
||||
|
||||
# 이미지 참조 제거 후 요약 (텍스트만)
|
||||
text_only = IMAGE_PATTERN.sub('', text).strip()
|
||||
|
||||
if len(text_only) < 100:
|
||||
return text_only
|
||||
|
||||
prompt = f"""
|
||||
아래 텍스트를 {limit}자 이내로 사실 기반으로 요약하라.
|
||||
추측 금지, 고유명사와 수치는 보존.
|
||||
|
||||
{text_only[:8000]}
|
||||
"""
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 사실만 요약하는 전문가이다."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
log(f"[WARN] summary 실패: {e}")
|
||||
return text_only[:limit]
|
||||
|
||||
|
||||
def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int:
|
||||
"""
|
||||
의미 청킹 → 요약 → JSON 저장
|
||||
|
||||
Returns:
|
||||
생성된 청크 수
|
||||
"""
|
||||
stem = src.stem
|
||||
folder_ctx = safe_rel(src.parent)
|
||||
|
||||
# 원문 저장
|
||||
(TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore")
|
||||
|
||||
# 의미 청킹
|
||||
chunks = semantic_chunk(domain_prompt, text, src.name)
|
||||
|
||||
if not chunks:
|
||||
log(f"[WARN] 청크 없음: {src.name}")
|
||||
return 0
|
||||
|
||||
rag_items = []
|
||||
|
||||
for idx, ch in enumerate(chunks, start=1):
|
||||
content = ch.get("content", "")
|
||||
|
||||
# 요약 생성
|
||||
summ = summary_chunk(domain_prompt, content, 300)
|
||||
|
||||
# 이 청크에 포함된 이미지 찾기
|
||||
images_in_chunk = find_images_in_text(content)
|
||||
|
||||
rag_items.append({
|
||||
"source": src.name,
|
||||
"source_path": safe_rel(src),
|
||||
"chunk": idx,
|
||||
"total_chunks": len(chunks),
|
||||
"title": ch.get("title", ""),
|
||||
"keywords": ch.get("keywords", ""),
|
||||
"text": content,
|
||||
"summary": summ,
|
||||
"folder_context": folder_ctx,
|
||||
"images": images_in_chunk,
|
||||
"has_images": len(images_in_chunk) > 0
|
||||
})
|
||||
|
||||
# JSON 저장
|
||||
(JSON_DIR / f"{stem}.json").write_text(
|
||||
json.dumps(rag_items, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# RAG용 JSON 저장
|
||||
(RAG_DIR / f"{stem}_chunks.json").write_text(
|
||||
json.dumps(rag_items, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
return len(chunks)
|
||||
|
||||
|
||||
def main():
|
||||
log("=" * 60)
|
||||
log("청킹/요약 파이프라인 시작")
|
||||
log(f"데이터 폴더: {DATA_ROOT}")
|
||||
log(f"출력 폴더: {OUTPUT_ROOT}")
|
||||
log("=" * 60)
|
||||
|
||||
# 도메인 프롬프트 로드
|
||||
domain_prompt = load_domain_prompt()
|
||||
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
|
||||
|
||||
# 통계
|
||||
stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0}
|
||||
|
||||
# .md 파일 찾기
|
||||
md_files = []
|
||||
for root, dirs, files in os.walk(DATA_ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
|
||||
for fname in files:
|
||||
if fname.lower().endswith(".md"):
|
||||
md_files.append(Path(root) / fname)
|
||||
|
||||
log(f"\n총 {len(md_files)}개 .md 파일 발견\n")
|
||||
|
||||
for idx, fpath in enumerate(md_files, 1):
|
||||
try:
|
||||
rel_path = safe_rel(fpath)
|
||||
log(f"[{idx}/{len(md_files)}] {rel_path}")
|
||||
|
||||
# 텍스트 읽기
|
||||
text = extract_text_md(fpath)
|
||||
|
||||
if not text.strip():
|
||||
log(f" ⚠ 빈 파일, 스킵")
|
||||
continue
|
||||
|
||||
# 이미지 개수 확인
|
||||
images = find_images_in_text(text)
|
||||
stats["images"] += len(images)
|
||||
|
||||
# 청킹 및 저장
|
||||
chunk_count = save_chunk_files(fpath, text, domain_prompt)
|
||||
|
||||
stats["docs"] += 1
|
||||
stats["chunks"] += chunk_count
|
||||
|
||||
log(f" ✓ {chunk_count}개 청크, {len(images)}개 이미지")
|
||||
|
||||
except Exception as e:
|
||||
stats["errors"] += 1
|
||||
log(f" ✗ 오류: {e}")
|
||||
|
||||
# 전체 통계 저장
|
||||
summary = {
|
||||
"processed_at": datetime.now().isoformat(),
|
||||
"data_root": str(DATA_ROOT),
|
||||
"output_root": str(OUTPUT_ROOT),
|
||||
"statistics": stats
|
||||
}
|
||||
|
||||
(LOG_DIR / "chunk_summary_stats.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# 결과 출력
|
||||
log("\n" + "=" * 60)
|
||||
log("청킹/요약 완료!")
|
||||
log("=" * 60)
|
||||
log(f"처리된 문서: {stats['docs']}개")
|
||||
log(f"생성된 청크: {stats['chunks']}개")
|
||||
log(f"포함된 이미지: {stats['images']}개")
|
||||
log(f"오류: {stats['errors']}개")
|
||||
log(f"\n결과 저장 위치:")
|
||||
log(f" - 원문: {TEXT_DIR}")
|
||||
log(f" - JSON: {JSON_DIR}")
|
||||
log(f" - RAG: {RAG_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
141
converters/pipeline/step5_rag.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
build_rag.py
|
||||
|
||||
기능:
|
||||
- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파일들을 읽어서
|
||||
text + summary 를 임베딩(text-embedding-3-small)한다.
|
||||
- FAISS IndexFlatIP 인덱스를 구축하여
|
||||
output/rag/faiss.index, meta.json, vectors.npy 를 생성한다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import faiss
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\survey_test\process")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
|
||||
RAG_DIR = OUTPUT_ROOT / "rag"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [RAG_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 (구조 유지) =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
EMBED_MODEL = "text-embedding-3-small"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
|
||||
def embed_texts(texts):
|
||||
if not texts:
|
||||
return np.zeros((0, 1536), dtype="float32")
|
||||
embs = []
|
||||
B = 96
|
||||
for i in range(0, len(texts), B):
|
||||
batch = texts[i:i+B]
|
||||
resp = client.embeddings.create(model=EMBED_MODEL, input=batch)
|
||||
for d in resp.data:
|
||||
embs.append(np.array(d.embedding, dtype="float32"))
|
||||
return np.vstack(embs)
|
||||
|
||||
|
||||
def _build_embed_input(u: dict) -> str:
|
||||
"""
|
||||
text + summary 를 합쳐 임베딩 입력을 만든다.
|
||||
- text, summary 중 없는 것은 생략
|
||||
- 공백 정리
|
||||
- 최대 길이 제한
|
||||
"""
|
||||
sum_ = (u.get("summary") or "").strip()
|
||||
txt = (u.get("text") or "").strip()
|
||||
|
||||
if txt and sum_:
|
||||
merged = txt + "\n\n요약: " + sum_[:1000]
|
||||
else:
|
||||
merged = txt or sum_
|
||||
|
||||
merged = " ".join(merged.split())
|
||||
if not merged:
|
||||
return ""
|
||||
if len(merged) > 4000:
|
||||
merged = merged[:4000]
|
||||
return merged
|
||||
|
||||
|
||||
def build_faiss_index():
|
||||
docs = []
|
||||
metas = []
|
||||
|
||||
rag_files = list(RAG_DIR.glob("*_chunks.json"))
|
||||
if not rag_files:
|
||||
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
|
||||
for f in rag_files:
|
||||
try:
|
||||
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
|
||||
except Exception as e:
|
||||
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
|
||||
continue
|
||||
|
||||
for u in units:
|
||||
embed_input = _build_embed_input(u)
|
||||
if not embed_input:
|
||||
continue
|
||||
if len(embed_input) < 40:
|
||||
continue
|
||||
docs.append(embed_input)
|
||||
metas.append({
|
||||
"source": u.get("source", ""),
|
||||
"chunk": int(u.get("chunk", 0)),
|
||||
"folder_context": u.get("folder_context", "")
|
||||
})
|
||||
|
||||
if not docs:
|
||||
log("임베딩할 텍스트가 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
log(f"임베딩 대상 텍스트 수: {len(docs)}")
|
||||
|
||||
E = embed_texts(docs)
|
||||
if E.shape[0] != len(docs):
|
||||
log(f"[WARN] 임베딩 수 불일치: E={E.shape[0]}, docs={len(docs)}")
|
||||
|
||||
faiss.normalize_L2(E)
|
||||
index = faiss.IndexFlatIP(E.shape[1])
|
||||
index.add(E)
|
||||
|
||||
np.save(str(RAG_DIR / "vectors.npy"), E)
|
||||
(RAG_DIR / "meta.json").write_text(
|
||||
json.dumps(metas, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
faiss.write_index(index, str(RAG_DIR / "faiss.index"))
|
||||
|
||||
log(f"FAISS 인덱스 구축 완료: 벡터 수={len(metas)}")
|
||||
|
||||
|
||||
def main():
|
||||
log("=== FAISS RAG 인덱스 구축 시작 ===")
|
||||
build_faiss_index()
|
||||
log("=== FAISS RAG 인덱스 구축 종료 ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
232
converters/pipeline/step6_corpus.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
make_corpus_v2.py
|
||||
|
||||
기능:
|
||||
- output/rag/*_chunks.json 에서 모든 청크의 summary를 모아
|
||||
- AI가 CEL 목적(교육+자사솔루션 홍보)에 맞게 압축 정리
|
||||
- 중복은 빈도 표시, 희귀하지만 중요한 건 [핵심] 표시
|
||||
- 결과를 output/context/corpus.txt 로 저장
|
||||
|
||||
전제:
|
||||
- chunk_and_summary.py 실행 후 *_chunks.json 들이 존재해야 한다.
|
||||
- domain_prompt.txt가 존재해야 한다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\survey_test\process")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
|
||||
RAG_DIR = OUTPUT_ROOT / "rag"
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== 압축 설정 =====
|
||||
BATCH_SIZE = 80 # 한 번에 처리할 요약 개수
|
||||
MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결과 글자수
|
||||
MAX_FINAL_CHARS = 8000 # 최종 corpus 글자수
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
|
||||
|
||||
|
||||
def load_domain_prompt() -> str:
|
||||
p = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if not p.exists():
|
||||
log("domain_prompt.txt가 없습니다. 먼저 step1을 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
def load_all_summaries() -> list:
|
||||
"""모든 청크의 summary + 출처 정보 수집"""
|
||||
summaries = []
|
||||
rag_files = sorted(RAG_DIR.glob("*_chunks.json"))
|
||||
|
||||
if not rag_files:
|
||||
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
|
||||
for f in rag_files:
|
||||
try:
|
||||
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
|
||||
except Exception as e:
|
||||
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
|
||||
continue
|
||||
|
||||
for u in units:
|
||||
summ = (u.get("summary") or "").strip()
|
||||
source = (u.get("source") or "").strip()
|
||||
keywords = (u.get("keywords") or "")
|
||||
|
||||
if summ:
|
||||
# 출처와 키워드 포함
|
||||
entry = f"[{source}] {summ}"
|
||||
if keywords:
|
||||
entry += f" (키워드: {keywords})"
|
||||
summaries.append(entry)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str:
|
||||
"""배치 단위로 요약들을 AI가 압축"""
|
||||
|
||||
batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)])
|
||||
|
||||
prompt = f"""
|
||||
아래는 문서에서 추출한 요약 {len(batch)}개이다. (배치 {batch_num}/{total_batches})
|
||||
|
||||
[요약 목록]
|
||||
{batch_text}
|
||||
|
||||
다음 기준으로 이 요약들을 압축 정리하라:
|
||||
|
||||
1) 중복/유사 내용: 하나로 통합하되, 여러 문서에서 언급되면 "(N회 언급)" 표시
|
||||
2) domain_prompt에 명시된 핵심 솔루션/시스템: 반드시 보존하고 [솔루션] 표시
|
||||
3) domain_prompt의 목적에 중요한 내용 우선 보존:
|
||||
- 해당 분야의 기초 개념
|
||||
- 기존 방식의 한계점과 문제점
|
||||
- 새로운 기술/방식의 장점
|
||||
4) 단순 나열/절차만 있는 내용: 과감히 축약
|
||||
5) 희귀하지만 핵심적인 인사이트: [핵심] 표시
|
||||
|
||||
출력 형식:
|
||||
- 주제별로 그룹핑
|
||||
- 각 항목은 1~2문장으로 간결하게
|
||||
- 전체 {MAX_CHARS_PER_BATCH}자 이내
|
||||
- 마크다운 없이 순수 텍스트로
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 문서 요약을 주제별로 압축 정리하는 전문가이다."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
result = resp.choices[0].message.content.strip()
|
||||
log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)")
|
||||
return result
|
||||
except Exception as e:
|
||||
log(f"[ERROR] 배치 {batch_num} 압축 실패: {e}")
|
||||
# 실패 시 원본 일부 반환
|
||||
return "\n".join(batch[:10])
|
||||
|
||||
|
||||
def merge_compressed_parts(domain_prompt: str, parts: list) -> str:
|
||||
"""배치별 압축 결과를 최종 통합"""
|
||||
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
|
||||
all_parts = "\n\n---\n\n".join([f"[파트 {i+1}]\n{p}" for i, p in enumerate(parts)])
|
||||
|
||||
prompt = f"""
|
||||
아래는 대량의 문서 요약을 배치별로 압축한 결과이다.
|
||||
이것을 최종 corpus로 통합하라.
|
||||
|
||||
[배치별 압축 결과]
|
||||
{all_parts}
|
||||
|
||||
통합 기준:
|
||||
1) 파트 간 중복 내용 제거 및 통합
|
||||
2) domain_prompt에 명시된 목적과 흐름에 맞게 재구성
|
||||
3) [솔루션], [핵심], (N회 언급) 표시는 유지
|
||||
4) 전체 {MAX_FINAL_CHARS}자 이내
|
||||
|
||||
출력: 주제별로 정리된 최종 corpus (마크다운 없이)
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 콘텐츠 기획을 위한 corpus를 설계하는 전문가이다."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
log(f"[ERROR] 최종 통합 실패: {e}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
log("=" * 60)
|
||||
log("corpus 생성 시작 (AI 압축 버전)")
|
||||
log("=" * 60)
|
||||
|
||||
# 도메인 프롬프트 로드
|
||||
domain_prompt = load_domain_prompt()
|
||||
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
|
||||
|
||||
# 모든 요약 수집
|
||||
summaries = load_all_summaries()
|
||||
if not summaries:
|
||||
log("summary가 없습니다. corpus를 생성할 수 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
log(f"원본 요약 수집 완료: {len(summaries)}개")
|
||||
|
||||
# 원본 저장 (백업)
|
||||
raw_corpus = "\n".join(summaries)
|
||||
raw_path = CONTEXT_DIR / "corpus_raw.txt"
|
||||
raw_path.write_text(raw_corpus, encoding="utf-8")
|
||||
log(f"원본 corpus 백업: {raw_path} ({len(raw_corpus)}자)")
|
||||
|
||||
# 배치별 압축
|
||||
total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE
|
||||
log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 총 {total_batches}배치)")
|
||||
|
||||
compressed_parts = []
|
||||
for i in range(0, len(summaries), BATCH_SIZE):
|
||||
batch = summaries[i:i+BATCH_SIZE]
|
||||
batch_num = (i // BATCH_SIZE) + 1
|
||||
|
||||
compressed = compress_batch(domain_prompt, batch, batch_num, total_batches)
|
||||
compressed_parts.append(compressed)
|
||||
|
||||
# 최종 통합
|
||||
log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파트)")
|
||||
final_corpus = merge_compressed_parts(domain_prompt, compressed_parts)
|
||||
|
||||
# 저장
|
||||
out_path = CONTEXT_DIR / "corpus.txt"
|
||||
out_path.write_text(final_corpus, encoding="utf-8")
|
||||
|
||||
# 통계
|
||||
log("\n" + "=" * 60)
|
||||
log("corpus 생성 완료!")
|
||||
log("=" * 60)
|
||||
log(f"원본 요약: {len(summaries)}개 ({len(raw_corpus)}자)")
|
||||
log(f"압축 corpus: {len(final_corpus)}자")
|
||||
log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%")
|
||||
log(f"\n저장 위치:")
|
||||
log(f" - 원본: {raw_path}")
|
||||
log(f" - 압축: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
504
converters/pipeline/step7_index.py
Normal file
@@ -0,0 +1,504 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
make_outline.py
|
||||
|
||||
기능:
|
||||
- output_context/context/domain_prompt.txt
|
||||
- output_context/context/corpus.txt
|
||||
을 기반으로 목차를 생성하고,
|
||||
|
||||
1) outline_issue_report.txt 저장
|
||||
2) outline_issue_report.html 저장 (테스트.html 레이아웃 기반 표 형태)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\survey_test\process")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 (구조 유지) =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== 목차 파싱용 정규식 보완 (5분할 대응) =====
|
||||
RE_KEYWORDS = re.compile(r"(#\S+)")
|
||||
RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$")
|
||||
RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$")
|
||||
RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$")
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
def load_domain_prompt() -> str:
|
||||
p = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if not p.exists():
|
||||
log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
def load_corpus() -> str:
|
||||
p = CONTEXT_DIR / "corpus.txt"
|
||||
if not p.exists():
|
||||
log("corpus.txt가 없습니다. 먼저 make_corpus.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
# 기존 RE_L1, RE_L2는 유지하고 아래 두 개를 추가/교체합니다.
|
||||
RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$")
|
||||
RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$")
|
||||
|
||||
def generate_outline(domain_prompt: str, corpus: str) -> str:
|
||||
sys_msg = {
|
||||
"role": "system",
|
||||
"content": (
|
||||
domain_prompt + "\n\n"
|
||||
"너는 건설/측량 DX 기술 보고서의 구조를 설계하는 시니어 기술사이다. "
|
||||
"주어진 corpus를 분석하여, 실무자가 즉시 활용 가능한 고밀도 지침서 목차를 설계하라."
|
||||
),
|
||||
}
|
||||
|
||||
user_msg = {
|
||||
"role": "user",
|
||||
"content": f"""
|
||||
아래 [corpus]를 바탕으로 보고서 제목과 전략적 목차를 설계하라.
|
||||
|
||||
[corpus]
|
||||
{corpus}
|
||||
|
||||
요구 사항:
|
||||
1) 첫 줄에 보고서 제목 1개를 작성하라.
|
||||
2) 그 아래 목차를 번호 기반 계측 구조로 작성하라.
|
||||
- 대목차: 1. / 2. / 3. ...
|
||||
- 중목차: 1.1 / 1.2 / ...
|
||||
- 소목차: 1.1.1 / 1.1.2 / ...
|
||||
3) **수량 제약 (중요)**:
|
||||
- 대목차(1.)는 5~8개로 구성하라.
|
||||
- **중목차(1.1) 하나당 소목차(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사이로 구성하라.** (절대 1개만 만들지 말 것)
|
||||
- 소목차(1.1.1) 하나당 '핵심주제(꼭지)'는 반드시 2개에서 3개 사이로 구성하라.
|
||||
|
||||
[소목차 작성 형식]
|
||||
1.1.1 소목차 제목
|
||||
- 핵심주제 1 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
|
||||
- 핵심주제 2 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
|
||||
|
||||
5) [유형] 분류 가이드:
|
||||
- [비교형]: 기존 vs DX 방식의 비교표(Table)가 필수적인 경우
|
||||
- [기술형]: RMSE, GSD, 중복도 등 정밀 수치와 사양 설명이 핵심인 경우
|
||||
- [절차형]: 단계별 워크플로 및 체크리스트가 중심인 경우
|
||||
- [인사이트형]: 한계점 분석 및 전문가 제언(☞)이 중심인 경우
|
||||
6) 집필가이드는 50자 내외로, "어떤 데이터를 검색해서 어떤 표를 그려라"와 같이 구체적으로 지시하라.
|
||||
7) 대목차는 최대 8개 이내로 구성하라.
|
||||
"""
|
||||
}
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[sys_msg, user_msg],
|
||||
)
|
||||
return (resp.choices[0].message.content or "").strip()
|
||||
|
||||
|
||||
|
||||
def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()]
|
||||
if not lines: return "", []
|
||||
|
||||
title = lines[0].strip() # 첫 줄은 보고서 제목
|
||||
rows = []
|
||||
current_section = None # 현재 처리 중인 소목차(1.1.1)를 추적
|
||||
|
||||
for ln in lines[1:]:
|
||||
raw = ln.strip()
|
||||
|
||||
# 1. 소목차 헤더(1.1.1 제목) 발견 시
|
||||
m3_head = RE_L3_HEAD.match(raw)
|
||||
if m3_head:
|
||||
num, s_title = m3_head.groups()
|
||||
current_section = {
|
||||
"depth": 3,
|
||||
"num": num,
|
||||
"title": s_title,
|
||||
"sub_topics": [] # 여기에 아래 줄의 꼭지들을 담을 예정
|
||||
}
|
||||
rows.append(current_section)
|
||||
continue
|
||||
|
||||
# 2. 세부 꼭지(- 주제 | #키워드 | [유형] | 가이드) 발견 시
|
||||
m_topic = RE_L3_TOPIC.match(raw)
|
||||
if m_topic and current_section:
|
||||
t_title, kws_raw, t_type, guide = m_topic.groups()
|
||||
# 키워드 추출 (#키워드 형태)
|
||||
kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)]
|
||||
|
||||
# 현재 소목차(current_section)의 리스트에 추가
|
||||
current_section["sub_topics"].append({
|
||||
"topic_title": t_title,
|
||||
"keywords": kws,
|
||||
"type": t_type,
|
||||
"guide": guide
|
||||
})
|
||||
continue
|
||||
|
||||
# 3. 대목차(1.) 처리
|
||||
m1 = RE_L1.match(raw)
|
||||
if m1:
|
||||
rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()})
|
||||
current_section = None # 소목차 구간 종료
|
||||
continue
|
||||
|
||||
# 4. 중목차(1.1) 처리
|
||||
m2 = RE_L2.match(raw)
|
||||
if m2:
|
||||
rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()})
|
||||
current_section = None # 소목차 구간 종료
|
||||
continue
|
||||
|
||||
return title, rows
|
||||
|
||||
def html_escape(s: str) -> str:
|
||||
s = s or ""
|
||||
return (s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'"))
|
||||
|
||||
def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]:
|
||||
"""
|
||||
A4 1장에 표가 길어지면 넘치므로, 단순 행 개수로 페이지 분할한다.
|
||||
"""
|
||||
out = []
|
||||
cur = []
|
||||
for r in rows:
|
||||
cur.append(r)
|
||||
if len(cur) >= max_rows_per_page:
|
||||
out.append(cur)
|
||||
cur = []
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
def build_outline_table_html(rows: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
테스트.html의 table 스타일을 그대로 쓰는 전제의 표 HTML
|
||||
"""
|
||||
head = """
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<th>번호</th>
|
||||
<th>제목</th>
|
||||
<th>키워드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
body_parts = []
|
||||
for r in rows:
|
||||
depth = r["depth"]
|
||||
num = html_escape(r["num"])
|
||||
title = html_escape(r["title"])
|
||||
kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k])
|
||||
kw = html_escape(kw)
|
||||
|
||||
if depth == 1:
|
||||
body_parts.append(
|
||||
f"""
|
||||
<tr>
|
||||
<td class="group-cell">대목차</td>
|
||||
<td>{num}</td>
|
||||
<td>{title}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
"""
|
||||
)
|
||||
elif depth == 2:
|
||||
body_parts.append(
|
||||
f"""
|
||||
<tr>
|
||||
<td class="group-cell">중목차</td>
|
||||
<td>{num}</td>
|
||||
<td>{title}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
body_parts.append(
|
||||
f"""
|
||||
<tr>
|
||||
<td class="group-cell">소목차</td>
|
||||
<td>{num}</td>
|
||||
<td>{title}</td>
|
||||
<td>{kw}</td>
|
||||
</tr>
|
||||
"""
|
||||
)
|
||||
|
||||
tail = """
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
return head + "\n".join(body_parts) + tail
|
||||
|
||||
def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
테스트.html 레이아웃 구조를 그대로 따라 A4 시트 형태로 HTML 생성
|
||||
"""
|
||||
css = r"""
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-blue: #3057B9;
|
||||
--gray-light: #F2F2F2;
|
||||
--gray-medium: #E6E6E6;
|
||||
--gray-dark: #666666;
|
||||
--border-light: #DDDDDD;
|
||||
--text-black: #000000;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: var(--text-black);
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background-color: white;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 20mm 20mm;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: none; padding: 0; }
|
||||
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
font-size: 8.5pt;
|
||||
color: var(--gray-dark);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24pt;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1.5px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.title-divider {
|
||||
height: 4px;
|
||||
background-color: var(--primary-blue);
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lead-box {
|
||||
background-color: var(--gray-light);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lead-box div {
|
||||
font-size: 13pt;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.lead-notes {
|
||||
font-size: 8.5pt;
|
||||
color: #777;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 5px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.body-content { flex: 1; }
|
||||
|
||||
.section { margin-bottom: 22px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 13pt;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #999;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 9.5pt;
|
||||
border-top: 1.5px solid #333;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--gray-medium);
|
||||
font-weight: 700;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.group-cell {
|
||||
background-color: #F9F9F9;
|
||||
font-weight: 700;
|
||||
width: 16%;
|
||||
text-align: center;
|
||||
color: var(--primary-blue);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
margin-top: 15px;
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8.5pt;
|
||||
color: var(--gray-dark);
|
||||
border-top: 1px solid #EEE;
|
||||
}
|
||||
|
||||
.footer-page { flex: 1; text-align: center; }
|
||||
"""
|
||||
|
||||
pages = chunk_rows(rows, max_rows_per_page=26)
|
||||
|
||||
html_pages = []
|
||||
total_pages = len(pages) if pages else 1
|
||||
for i, page_rows in enumerate(pages, start=1):
|
||||
table_html = build_outline_table_html(page_rows)
|
||||
|
||||
html_pages.append(f"""
|
||||
<div class="sheet">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
보고서: 목차 자동 생성 결과
|
||||
</div>
|
||||
<div class="header-right">
|
||||
작성일자: {datetime.now().strftime("%Y. %m. %d.")}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="title-block">
|
||||
<h1 class="header-title">{html_escape(report_title)}</h1>
|
||||
<div class="title-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="body-content">
|
||||
<div class="lead-box">
|
||||
<div>확정 목차 표 형태 정리본</div>
|
||||
</div>
|
||||
<div class="lead-notes">목차는 outline_issue_report.txt를 기반으로 표로 재구성됨</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">목차</div>
|
||||
{table_html}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="page-footer">
|
||||
<div class="footer-slogan">Word Style v2 Outline</div>
|
||||
<div class="footer-page">- {i} / {total_pages} -</div>
|
||||
<div class="footer-info">outline_issue_report.html</div>
|
||||
</footer>
|
||||
</div>
|
||||
""")
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{html_escape(report_title)} - Outline</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
{''.join(html_pages)}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def main():
|
||||
log("=== 목차 생성 시작 ===")
|
||||
domain_prompt = load_domain_prompt()
|
||||
corpus = load_corpus()
|
||||
|
||||
outline = generate_outline(domain_prompt, corpus)
|
||||
|
||||
# TXT 저장 유지
|
||||
out_txt = CONTEXT_DIR / "outline_issue_report.txt"
|
||||
out_txt.write_text(outline, encoding="utf-8")
|
||||
log(f"목차 TXT 저장 완료: {out_txt}")
|
||||
|
||||
# HTML 추가 저장
|
||||
title, rows = parse_outline(outline)
|
||||
out_html = CONTEXT_DIR / "outline_issue_report.html"
|
||||
out_html.write_text(build_outline_html(title, rows), encoding="utf-8")
|
||||
log(f"목차 HTML 저장 완료: {out_html}")
|
||||
|
||||
log("=== 목차 생성 종료 ===")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1021
converters/pipeline/step8_content.py
Normal file
1249
converters/pipeline/step9_html.py
Normal file
BIN
output/assets/1_1_1_img01.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
output/assets/1_1_1_img02.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
output/assets/1_1_1_img03.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
output/assets/1_1_2_img01.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
output/assets/1_1_2_img02.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
output/assets/1_1_2_img03.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
output/assets/1_1_3_img01.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
output/assets/1_1_3_img02.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
output/assets/1_2_1_img03.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
output/assets/1_2_2_img01.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
output/assets/1_2_2_img02.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
output/assets/1_2_2_img03.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
@@ -1073,6 +1073,7 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/editor.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 상단 툴바 -->
|
||||
@@ -1081,7 +1082,7 @@
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<button class="toolbar-btn" id="editBtn" onclick="toggleEditMode()">✏️ 편집하기</button>
|
||||
<button class="toolbar-btn" id="editModeBtn" onclick="toggleEditMode()">✏️ 편집하기</button>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
@@ -1095,7 +1096,9 @@
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<button class="toolbar-btn" onclick="exportHwp()">📄 HWP 추출</button>
|
||||
<button class="toolbar-btn" onclick="saveHtml()">💾 HTML 저장</button>
|
||||
<button class="toolbar-btn" disabled title="준비중">📊 PPT 저장</button>
|
||||
<button class="toolbar-btn" onclick="printDoc()">🖨️ PDF/인쇄</button>
|
||||
</div>
|
||||
|
||||
@@ -1299,10 +1302,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 보고서 -->
|
||||
<div class="doc-type-item disabled" data-type="report">
|
||||
<input type="radio" name="docType" disabled>
|
||||
<span class="label">📄 보고서</span>
|
||||
<span class="badge">준비중</span>
|
||||
<div class="doc-type-item" data-type="report" onclick="selectDocType('report')">
|
||||
<input type="radio" name="docType">
|
||||
<span class="label">📄 보고서</span>
|
||||
|
||||
<div class="doc-type-preview">
|
||||
<div class="preview-thumbnail report">
|
||||
@@ -1373,15 +1375,15 @@
|
||||
<div class="option-group">
|
||||
<div class="option-item" onclick="selectPageOption('1')">
|
||||
<input type="radio" name="pages" value="1" id="page1">
|
||||
<label for="page1">1p (본문만)</label>
|
||||
<label for="page1"> (본문) 1p</label>
|
||||
</div>
|
||||
<div class="option-item selected" onclick="selectPageOption('2')">
|
||||
<input type="radio" name="pages" value="2" id="page2" checked>
|
||||
<label for="page2">1p + 1p 첨부</label>
|
||||
<label for="page2"> (본문) 1p + (첨부) 1p</label>
|
||||
</div>
|
||||
<div class="option-item" onclick="selectPageOption('n')">
|
||||
<input type="radio" name="pages" value="n" id="pageN">
|
||||
<label for="pageN">1p + np 첨부 (자동)</label>
|
||||
<label for="pageN"> (본문) 1p + (첨부) np</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1393,6 +1395,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보고서 옵션 -->
|
||||
<div id="reportOptions" style="display:none;">
|
||||
<!-- 보고서 구성 -->
|
||||
<div class="option-section">
|
||||
<div class="option-title">보고서 구성</div>
|
||||
<div class="option-group">
|
||||
<div class="option-item" style="cursor:default;">
|
||||
<input type="checkbox" id="reportCover" checked>
|
||||
<label for="reportCover">📘 표지</label>
|
||||
</div>
|
||||
<div class="option-item" style="cursor:default;">
|
||||
<input type="checkbox" id="reportToc" checked>
|
||||
<label for="reportToc">📑 목차</label>
|
||||
</div>
|
||||
<div class="option-item" style="cursor:default;">
|
||||
<input type="checkbox" id="reportDivider">
|
||||
<label for="reportDivider">📄 간지</label>
|
||||
</div>
|
||||
<div class="option-item" style="cursor:default; opacity:0.6;">
|
||||
<input type="checkbox" id="reportContent" checked disabled>
|
||||
<label for="reportContent">📝 내지 (필수)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요청사항 -->
|
||||
<div class="option-section">
|
||||
<div class="option-title">요청사항</div>
|
||||
<textarea class="request-textarea" id="reportInstructionInput" placeholder="예: 요약을 상세하게 작성해줘 예: 표지에 로고 추가"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 생성 버튼 -->
|
||||
<button class="generate-btn" id="generateBtn" onclick="generate()" disabled>
|
||||
<span id="generateBtnText">🚀 생성하기</span>
|
||||
@@ -1463,7 +1502,6 @@
|
||||
let generatedHTML = '';
|
||||
let currentDocType = 'briefing';
|
||||
let currentPageOption = '2';
|
||||
let isEditing = false;
|
||||
let currentZoom = 100;
|
||||
let folderPath = '';
|
||||
let referenceLinks = [];
|
||||
@@ -1472,6 +1510,53 @@
|
||||
let selectedText = '';
|
||||
let selectedRange = null;
|
||||
|
||||
// ===== HWP 추출 =====
|
||||
async function exportHwp() {
|
||||
if (!generatedHTML) {
|
||||
alert('먼저 문서를 생성해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 편집된 HTML 가져오기
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const html = frame.contentDocument ?
|
||||
'<!DOCTYPE html>' + frame.contentDocument.documentElement.outerHTML :
|
||||
generatedHTML;
|
||||
|
||||
setStatus('HWP 변환 중...', true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/export-hwp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
doc_type: currentDocType
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'HWP 변환 실패');
|
||||
}
|
||||
|
||||
// 파일 다운로드
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `report_${new Date().toISOString().slice(0,10)}.hwp`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setStatus('HWP 변환 완료', true);
|
||||
|
||||
} catch (error) {
|
||||
alert('HWP 변환 오류: ' + error.message);
|
||||
setStatus('오류 발생', false);
|
||||
}
|
||||
}
|
||||
|
||||
// iframe 로드 후 선택 이벤트 연결
|
||||
function setupIframeSelection() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
@@ -1815,8 +1900,8 @@
|
||||
|
||||
// ===== 문서 유형 선택 =====
|
||||
function selectDocType(type) {
|
||||
if (type !== 'briefing') {
|
||||
return; // disabled 항목 클릭 무시
|
||||
if (type === 'presentation') {
|
||||
return; // PPT만 disabled
|
||||
}
|
||||
|
||||
currentDocType = type;
|
||||
@@ -1827,6 +1912,10 @@
|
||||
item.querySelector('input[type="radio"]').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 옵션 패널 표시/숨김
|
||||
document.getElementById('briefingOptions').style.display = (type === 'briefing') ? 'block' : 'none';
|
||||
document.getElementById('reportOptions').style.display = (type === 'report') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ===== 템플릿 추가 =====
|
||||
@@ -1846,6 +1935,15 @@
|
||||
|
||||
// ===== 생성 =====
|
||||
async function generate() {
|
||||
if (currentDocType === 'briefing') {
|
||||
await generateBriefing();
|
||||
} else if (currentDocType === 'report') {
|
||||
await generateReport();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 기획서 생성 (기존 로직) =====
|
||||
async function generateBriefing() {
|
||||
if (!inputContent && !folderPath && referenceLinks.length === 0) {
|
||||
alert('먼저 폴더 위치, 참고 링크, 또는 HTML을 입력해주세요.');
|
||||
return;
|
||||
@@ -1900,17 +1998,12 @@
|
||||
|
||||
if (data.success && data.html) {
|
||||
generatedHTML = data.html;
|
||||
|
||||
// 미리보기 표시
|
||||
document.getElementById('placeholder').style.display = 'none';
|
||||
const frame = document.getElementById('previewFrame');
|
||||
frame.classList.add('active');
|
||||
frame.srcdoc = generatedHTML;
|
||||
setTimeout(setupIframeSelection, 500);
|
||||
|
||||
// 피드백 바 표시
|
||||
setTimeout(setupIframeSelection, 500); // ← 이 줄 추가
|
||||
document.getElementById('feedbackBar').classList.add('show');
|
||||
|
||||
setStatus('생성 완료', true);
|
||||
}
|
||||
|
||||
@@ -1931,8 +2024,85 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 보고서 생성 (새로 추가) =====
|
||||
async function generateReport() {
|
||||
if (!folderPath && !inputContent) {
|
||||
alert('폴더 위치 또는 HTML을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('generateBtn');
|
||||
const btnText = document.getElementById('generateBtnText');
|
||||
const spinner = document.getElementById('generateSpinner');
|
||||
|
||||
btn.disabled = true;
|
||||
btnText.textContent = '생성 중...';
|
||||
spinner.style.display = 'block';
|
||||
resetSteps();
|
||||
|
||||
// 체크박스 값 수집
|
||||
const options = {
|
||||
content: inputContent, // ← 추가!
|
||||
folder_path: folderPath,
|
||||
cover: document.getElementById('reportCover').checked,
|
||||
toc: document.getElementById('reportToc').checked,
|
||||
divider: document.getElementById('reportDivider').checked,
|
||||
instruction: document.getElementById('reportInstructionInput').value
|
||||
};
|
||||
|
||||
setStatus('보고서 생성 중...', true);
|
||||
|
||||
try {
|
||||
// Step 1~9 진행 표시
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
updateStep(i, 'running');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
// TODO: 실제 API 호출
|
||||
updateStep(i, 'done');
|
||||
}
|
||||
|
||||
const response = await fetch('/generate-report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: inputContent, // HTML 내용 추가
|
||||
folder_path: folderPath,
|
||||
cover: document.getElementById('reportCover').checked,
|
||||
toc: document.getElementById('reportToc').checked,
|
||||
divider: document.getElementById('reportDivider').checked,
|
||||
instruction: document.getElementById('reportInstructionInput').value
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
if (data.success && data.html) {
|
||||
generatedHTML = data.html;
|
||||
document.getElementById('placeholder').style.display = 'none';
|
||||
const frame = document.getElementById('previewFrame');
|
||||
frame.classList.add('active');
|
||||
frame.srcdoc = generatedHTML;
|
||||
setTimeout(setupIframeSelection, 500); // ← 추가!
|
||||
document.getElementById('feedbackBar').classList.add('show');
|
||||
setStatus('생성 완료', true);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
alert('생성 오류: ' + error.message);
|
||||
setStatus('오류 발생', false);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.textContent = '🚀 생성하기';
|
||||
spinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 피드백 수정 =====
|
||||
async function submitFeedback() {
|
||||
async function submitFeedback() {
|
||||
const feedback = document.getElementById('feedbackInput').value.trim();
|
||||
if (!feedback) {
|
||||
alert('수정 내용을 입력해주세요.');
|
||||
@@ -1998,28 +2168,6 @@ async function submitFeedback() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 편집 모드 =====
|
||||
function toggleEditMode() {
|
||||
isEditing = !isEditing;
|
||||
const btn = document.getElementById('editBtn');
|
||||
const formatBar = document.getElementById('formatBar');
|
||||
const frame = document.getElementById('previewFrame');
|
||||
|
||||
btn.classList.toggle('active', isEditing);
|
||||
formatBar.classList.toggle('active', isEditing);
|
||||
|
||||
if (frame.contentDocument) {
|
||||
frame.contentDocument.designMode = isEditing ? 'on' : 'off';
|
||||
}
|
||||
}
|
||||
|
||||
function formatText(command) {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
if (frame.contentDocument) {
|
||||
frame.contentDocument.execCommand(command, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 줌 =====
|
||||
function setZoom(value) {
|
||||
currentZoom = parseInt(value);
|
||||
@@ -2094,6 +2242,6 @@ async function submitFeedback() {
|
||||
<textarea class="ai-edit-input" id="aiEditInput" rows="3" placeholder="예: 한 줄로 요약해줘 예: 표 형태로 만들어줘"></textarea>
|
||||
<button class="ai-edit-btn" onclick="submitAiEdit()">✨ 수정하기</button>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||