📦 Initialize Geulbeot structure and merge Prompts & test projects
This commit is contained in:
13
02. Prompts/문서생성/codedomain/국토일보_한건신문_Python_v01.py
Normal file
13
02. Prompts/문서생성/codedomain/국토일보_한건신문_Python_v01.py
Normal file
@@ -0,0 +1,13 @@
|
||||
def format_date(date_str, source):
|
||||
try:
|
||||
if source in ["국토일보", "한건신문"]:
|
||||
# 기자 이름과 함께 있는 날짜 형식 처리
|
||||
date_obj = re.search(r'\d{4}-\d{2}-\d{2}', date_str)
|
||||
if date_obj:
|
||||
return date_obj.group(0)
|
||||
elif source in ["엔지니어링데일리", "건설이코노미뉴스", "공학저널"]:
|
||||
# 기자 이름과 함께 있는 날짜 형식 처리
|
||||
date_obj = re.search(r'\d{4}-\d{2}-\d{2}', date_str)
|
||||
if date_obj:
|
||||
return date_obj.group(0)
|
||||
elif source == "연합
|
||||
11
02. Prompts/문서생성/codedomain/날짜_형식을_Python_v01.py
Normal file
11
02. Prompts/문서생성/codedomain/날짜_형식을_Python_v01.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def format_date(date_str: str, source: str) -> str:
|
||||
"""날짜 형식을 YYYY-MM-DD 로 변환"""
|
||||
try:
|
||||
match = re.search(r'\d{4}-\d{2}-\d{2}', date_str)
|
||||
if match:
|
||||
return match.group(0)
|
||||
if source == '연합뉴스':
|
||||
return datetime.strptime(date_str, '%m-%d %H:%M').strftime('2024-%m-%d')
|
||||
return date_str
|
||||
except Exception:
|
||||
return date_str
|
||||
13
02. Prompts/문서생성/codedomain/다음_로우데이터를_Python_v01.py
Normal file
13
02. Prompts/문서생성/codedomain/다음_로우데이터를_Python_v01.py
Normal file
@@ -0,0 +1,13 @@
|
||||
def summarize_data_for(section: str):
|
||||
texts = []
|
||||
for path in sorted(os.listdir(DATA_DIR)):
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
texts.append(f.read())
|
||||
prompt = (
|
||||
f"다음 로우데이터를 바탕으로 ‘{section}’ 섹션에 들어갈 핵심 사실과 수치를 200~300자로 요약해주세요.\n\n"
|
||||
+ "\n\n".join(texts)
|
||||
)
|
||||
return call_claude(prompt)
|
||||
|
||||
|
||||
# ─── 4) 이미지 자동 매핑 ─────────────────────────
|
||||
20
02. Prompts/문서생성/codedomain/단위일_가능성_Python_v01.py
Normal file
20
02. Prompts/문서생성/codedomain/단위일_가능성_Python_v01.py
Normal file
@@ -0,0 +1,20 @@
|
||||
def is_likely_unit(cell_val):
|
||||
"""단위일 가능성 판별 (사용자 제안 로직)"""
|
||||
if not cell_val:
|
||||
return False
|
||||
val = str(cell_val).strip()
|
||||
|
||||
# 1. 빈 값 또는 너무 긴 텍스트 (단위는 보통 6자 이내)
|
||||
if not val or len(val) > 6:
|
||||
return False
|
||||
|
||||
# 2. 순수 숫자는 제외
|
||||
cleaned = val.replace('.', '').replace(',', '').replace('-', '').replace(' ', '')
|
||||
if cleaned.isdigit():
|
||||
return False
|
||||
|
||||
# 3. 수식은 제외
|
||||
if val.startswith('='):
|
||||
return False
|
||||
|
||||
# 4. 일반적인 계산 기호 및 정크 기호 제외
|
||||
12
02. Prompts/문서생성/codedomain/단일_기사_Python_v01.py
Normal file
12
02. Prompts/문서생성/codedomain/단일_기사_Python_v01.py
Normal file
@@ -0,0 +1,12 @@
|
||||
def fetch_article_content(url: str, source: str) -> str:
|
||||
"""단일 기사 본문 추출"""
|
||||
try:
|
||||
resp = requests.get(url, verify=False, timeout=10)
|
||||
resp.encoding = 'utf-8'
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||
paragraphs = soup.find_all('p')
|
||||
content = ' '.join(clean_text(p.get_text()) for p in paragraphs)
|
||||
content = content.replace('\n', ' ')
|
||||
if not content.strip():
|
||||
logging.warning(f'No content for
|
||||
8
02. Prompts/문서생성/codedomain/당신은_보고서_Python_v01.py
Normal file
8
02. Prompts/문서생성/codedomain/당신은_보고서_Python_v01.py
Normal file
@@ -0,0 +1,8 @@
|
||||
def analyze_references():
|
||||
files = sorted(os.listdir(REF_DIR))
|
||||
sys = "당신은 보고서 전문가입니다. 아래 파일명들을 보고, 이 프로젝트에 어울리는 보고서 스타일과 목차 구조를 요약해 주세요."
|
||||
usr = "파일 목록:\n" + "\n".join(files)
|
||||
return call_gpt(sys, usr)
|
||||
|
||||
|
||||
# ─── 2) 가이드라인에서 필수 섹션 추출 ───────────
|
||||
13
02. Prompts/문서생성/codedomain/로그_전체_Python_v01.py
Normal file
13
02. Prompts/문서생성/codedomain/로그_전체_Python_v01.py
Normal file
@@ -0,0 +1,13 @@
|
||||
def run_global_reconstruction(input_file):
|
||||
print("로그: 전체 시트 통합 데이터를 분석 중입니다...")
|
||||
df = pd.read_excel(input_file)
|
||||
|
||||
# 1. 전역 주소록 생성: (시트명, 셀위치) -> 값
|
||||
# 예: { ('A1', 'G105'): 30.901, ('철근집계', 'C47'): 159.263 }
|
||||
global_map = {}
|
||||
for _, row in df.iterrows():
|
||||
global_map[(str(row['시트명']), str(row['셀위치']))] = row['현재값']
|
||||
|
||||
def trace_logic(formula, current_sheet):
|
||||
if not isinstance(formula, str) or not formula.startswith("'="):
|
||||
return formula
|
||||
17
02. Prompts/문서생성/codedomain/로그_파일을_Python_v01.py
Normal file
17
02. Prompts/문서생성/codedomain/로그_파일을_Python_v01.py
Normal file
@@ -0,0 +1,17 @@
|
||||
def extract_all_contents(file_path):
|
||||
print(f"로그: 파일을 읽는 중입니다 (전체 내용 모드)...")
|
||||
# 수식과 값을 동시에 비교하기 위해 data_only=False로 로드
|
||||
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||
|
||||
all_content_data = []
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
print(f"\n" + "="*60)
|
||||
print(f"▶ 시트 탐색 중: [ {sheet_name} ]")
|
||||
print("="*60)
|
||||
|
||||
# 시트의 모든 셀을 하나하나 검사
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
value = ce
|
||||
18
02. Prompts/문서생성/codedomain/리스트_페이지_Python_v01.py
Normal file
18
02. Prompts/문서생성/codedomain/리스트_페이지_Python_v01.py
Normal file
@@ -0,0 +1,18 @@
|
||||
def fetch_articles(
|
||||
base_url: str,
|
||||
article_sel: str,
|
||||
title_sel: str,
|
||||
date_sel: str,
|
||||
start_page: int,
|
||||
end_page: int,
|
||||
source: str,
|
||||
url_prefix: str = '',
|
||||
date_fmt_func=None
|
||||
) -> list:
|
||||
"""리스트 페이지 순회하며 메타데이터 및 본문 수집"""
|
||||
results = []
|
||||
for page in range(start_page, end_page + 1):
|
||||
try:
|
||||
page_url = f"{base_url}{page}"
|
||||
resp = requests.get(page_url, verify=False, timeout=10)
|
||||
soup = BeautifulSoup(resp.text, 'html.parser
|
||||
11
02. Prompts/문서생성/codedomain/멀티라인_대응_Python_v01.py
Normal file
11
02. Prompts/문서생성/codedomain/멀티라인_대응_Python_v01.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def get_item_id_with_lookback(ws, row, col, section_start_row):
|
||||
"""멀티라인 대응 상향 번호 탐색 - 섹션 경계 존중"""
|
||||
for r in range(row, section_start_row - 1, -1):
|
||||
# 새로운 섹션을 만나면 탐색 중단
|
||||
f_val_check = str(ws.cell(row=r, column=6).value or "").strip()
|
||||
if r != row and re.match(r'^\(.*\)$|^\[.*\]$', f_val_check):
|
||||
break
|
||||
|
||||
# F열에서 번호 탐색
|
||||
if re.search(ID_MARKER_PATTERN, f_val_check):
|
||||
return re.search(ID_MARKER_PATTERN, f_val_check).group()
|
||||
14
02. Prompts/문서생성/codedomain/미분류_과업_Python_v01.py
Normal file
14
02. Prompts/문서생성/codedomain/미분류_과업_Python_v01.py
Normal file
@@ -0,0 +1,14 @@
|
||||
def collect_app_usage(days_back):
|
||||
server = 'localhost'
|
||||
log_type = 'Security'
|
||||
hand = win32evtlog.OpenEventLog(server, log_type)
|
||||
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
||||
|
||||
usage_records = []
|
||||
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=days_back)
|
||||
|
||||
events = True
|
||||
while events:
|
||||
events = win32evtlog.ReadEventLog(hand, flags, 0)
|
||||
for ev_obj in events:
|
||||
event_time = ev_obj.TimeGenerated
|
||||
11
02. Prompts/문서생성/codedomain/법령_지침_Python_v01.py
Normal file
11
02. Prompts/문서생성/codedomain/법령_지침_Python_v01.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def extract_must_have_sections():
|
||||
texts = []
|
||||
for path in sorted(os.listdir(GUIDELINE_DIR)):
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
texts.append(f.read())
|
||||
sys = "법령·지침 문서를 바탕으로, 보고서에 반드시 들어가야 할 섹션(목차)을 순서대로 나열해 주세요."
|
||||
usr = "\n\n---\n\n".join(texts)
|
||||
return call_gpt(sys, usr).splitlines()
|
||||
|
||||
|
||||
# ─── 3) 로우데이터에서 섹션별 내용 뽑기 ───────────
|
||||
16
02. Prompts/문서생성/codedomain/보고서_섹션에_Python_v01.py
Normal file
16
02. Prompts/문서생성/codedomain/보고서_섹션에_Python_v01.py
Normal file
@@ -0,0 +1,16 @@
|
||||
def pick_images_for(section: str):
|
||||
names = sorted(os.listdir(IMAGE_DIR))
|
||||
prompt = (
|
||||
f"보고서 ‘{section}’ 섹션에 적합한 이미지를 아래 목록에서 1~2개 추천해 파일명만 리턴하세요:\n"
|
||||
+ "\n".join(names)
|
||||
)
|
||||
resp = call_gpt("당신은 디자인 어시스턴트입니다.", prompt)
|
||||
picked = []
|
||||
for line in resp.splitlines():
|
||||
fn = line.strip()
|
||||
if fn in names:
|
||||
picked.append(fn)
|
||||
return picked
|
||||
|
||||
|
||||
# ─── 5) 디자인 템플릿 선택 ───────────────────────
|
||||
13
02. Prompts/문서생성/codedomain/사이트별_함수_Python_v01.py
Normal file
13
02. Prompts/문서생성/codedomain/사이트별_함수_Python_v01.py
Normal file
@@ -0,0 +1,13 @@
|
||||
class SslAdapter(HTTPAdapter):
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.set_ciphers('DEFAULT:@SECLEVEL=1')
|
||||
self.poolmanager = PoolManager(*args, ssl_context=ctx, **kwargs)
|
||||
|
||||
session = requests.Session()
|
||||
session.mount('https://', SslAdapter())
|
||||
headers = {'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'ko-KR,ko;q=0.9'}
|
||||
|
||||
# -------------------------------------------------
|
||||
# 사이트별 함수 (대한경제 제외)
|
||||
# -----------------------------------
|
||||
7
02. Prompts/문서생성/codedomain/설명이_없습니다_Python_v01.py
Normal file
7
02. Prompts/문서생성/codedomain/설명이_없습니다_Python_v01.py
Normal file
@@ -0,0 +1,7 @@
|
||||
def get_detail_content(detail_url):
|
||||
res = requests.get(detail_url)
|
||||
soup = BeautifulSoup(res.text, 'html.parser')
|
||||
div = soup.find('div', {'data-v-5cb2d9fe': True})
|
||||
if div and div.find('h2'):
|
||||
return div.find('h2').text.strip()
|
||||
return "설명이 없습니다."
|
||||
11
02. Prompts/문서생성/codedomain/설정_브라우저가_Python_v01.py
Normal file
11
02. Prompts/문서생성/codedomain/설정_브라우저가_Python_v01.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def fetch_dnews_articles(base_url, start_page, end_page):
|
||||
# Selenium WebDriver 설정
|
||||
options = webdriver.ChromeOptions()
|
||||
options.add_argument('--headless') # 브라우저가 뜨지 않게 설정
|
||||
options.add_argument('--no-sandbox')
|
||||
options.add_argument('--disable-dev-shm-usage')
|
||||
|
||||
# ChromeDriver 경로 설정
|
||||
chromedriver_path = 'D:/python_for crawling/webdriver/chromedriver-win64/chromedriver.exe' # ChromeDriver 경로 설정
|
||||
service = ChromeService(executable_path=chromedriver_path)
|
||||
driver = webdr
|
||||
17
02. Prompts/문서생성/codedomain/수식_자체가_Python_v01.py
Normal file
17
02. Prompts/문서생성/codedomain/수식_자체가_Python_v01.py
Normal file
@@ -0,0 +1,17 @@
|
||||
def extract_raw_constants(file_path):
|
||||
# 수식 자체가 아닌 입력된 값을 확인하기 위해 로드
|
||||
print(f"로그: 파일을 읽는 중입니다...")
|
||||
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||
|
||||
raw_data = []
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
print(f"\n" + "="*50)
|
||||
print(f"▶ [ {sheet_name} ] 시트의 원천 데이터(상수) 추출 시작")
|
||||
print("="*50)
|
||||
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
value = cell.value
|
||||
coord = cell.coordin
|
||||
11
02. Prompts/문서생성/codedomain/수식_주소를_Python_v01.py
Normal file
11
02. Prompts/문서생성/codedomain/수식_주소를_Python_v01.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def reconstruct_formula(formula, wb_v, sheet_name):
|
||||
"""수식 내 셀 주소를 실제 값으로 치환 및 기호 가독화"""
|
||||
if not formula or not str(formula).startswith('='): return str(formula)
|
||||
ref_pattern = r"(?:'([^']+)'|([a-zA-Z0-9가-힣]+))?!([A-Z]+\d+)|([A-Z]+\d+)"
|
||||
|
||||
def replace_with_value(match):
|
||||
s_name = match.group(1) or match.group(2) or sheet_name
|
||||
coord = match.group(3) or match.group(4)
|
||||
try:
|
||||
val = wb_v[s_name][coord].value
|
||||
if val is None: return "0"
|
||||
14
02. Prompts/문서생성/codedomain/수식을_가져오기_Python_v01.py
Normal file
14
02. Prompts/문서생성/codedomain/수식을_가져오기_Python_v01.py
Normal file
@@ -0,0 +1,14 @@
|
||||
def extract_excel_logic(file_path):
|
||||
# 1. 수식을 가져오기 위한 로드 (data_only=False)
|
||||
print(f"로그: 파일을 읽는 중입니다 (수식 모드)...")
|
||||
wb_formula = openpyxl.load_workbook(file_path, data_only=False)
|
||||
|
||||
# 2. 결과값을 가져오기 위한 로드 (data_only=True)
|
||||
print(f"로그: 파일을 읽는 중입니다 (데이터 모드)...")
|
||||
wb_value = openpyxl.load_workbook(file_path, data_only=True)
|
||||
|
||||
extraction_data = []
|
||||
|
||||
for sheet_name in wb_formula.sheetnames:
|
||||
ws_f = wb_formula[sheet_name]
|
||||
ws_v = wb_value[sheet_name]
|
||||
11
02. Prompts/문서생성/codedomain/아래_디자인_Python_v01.py
Normal file
11
02. Prompts/문서생성/codedomain/아래_디자인_Python_v01.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def choose_design_template():
|
||||
samples = sorted(os.listdir(DESIGN_DIR))
|
||||
prompt = (
|
||||
"아래 디자인 샘플 파일들 중 이 보고서에 어울리는 상위 3안(1안,2안,3안)을 "
|
||||
"순서대로 파일명만으로 알려주세요:\n" + "\n".join(samples)
|
||||
)
|
||||
lines = call_gpt("디자인 전문가입니다.", prompt).splitlines()
|
||||
return [ln.strip() for ln in lines if ln.strip() in samples][:3]
|
||||
|
||||
|
||||
# ─── PPT 생성 ────────────────────────────────────
|
||||
13
02. Prompts/문서생성/codedomain/엔지니어링데일리_기자_Python_v01.py
Normal file
13
02. Prompts/문서생성/codedomain/엔지니어링데일리_기자_Python_v01.py
Normal file
@@ -0,0 +1,13 @@
|
||||
def clean_text(text):
|
||||
replacements = {
|
||||
' ': ' ', '‘': "'", '’': "'", '“': '"', '”': '"',
|
||||
'&': '&', '<': '<', '>': '>', ''': "'",
|
||||
'"' : "'", '·': "'"
|
||||
}
|
||||
|
||||
for entity, replacement in replacements.items():
|
||||
text = text.replace(entity, replacement)
|
||||
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'\(엔지니어링데일리\).*?기자=', '', text) # (엔지니어링데일리) *** 기자= 패턴 삭제
|
||||
text = re.sub(r'\[국토일보\s.*?
|
||||
9
02. Prompts/문서생성/codedomain/엔티티_불필요한_Python_v01.py
Normal file
9
02. Prompts/문서생성/codedomain/엔티티_불필요한_Python_v01.py
Normal file
@@ -0,0 +1,9 @@
|
||||
def clean_text(text: str) -> str:
|
||||
"""HTML 엔티티 및 불필요한 태그 제거"""
|
||||
reps = {
|
||||
' ': ' ', '‘': "'", '’': "'", '“': '"', '”': '"',
|
||||
'&': '&', '<': '<', '>': '>', ''': "'", '"': "'", '·': "'"
|
||||
}
|
||||
for key, val in reps.items():
|
||||
text = text.replace(key, val)
|
||||
return re.sub(r'<[^>]+>', '', text).strip()
|
||||
11
02. Prompts/문서생성/codedomain/인증서_검증_Python_v01.py
Normal file
11
02. Prompts/문서생성/codedomain/인증서_검증_Python_v01.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def fetch_article_content(article_url, source):
|
||||
try:
|
||||
response = requests.get(article_url, verify=False, timeout=10) # SSL 인증서 검증 비활성화 및 타임아웃 설정
|
||||
response.encoding = 'utf-8' # 인코딩 설정
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
paragraphs = soup.find_all('p')
|
||||
content = ' '.join([clean_text(p.get_text()) for p in paragraphs])
|
||||
|
||||
# 텍스트 내의 엔터키를 스페이스로 대체
|
||||
content = content.replace('\n', ' ')
|
||||
14
02. Prompts/문서생성/codedomain/카테고리_내용_Python_v01.py
Normal file
14
02. Prompts/문서생성/codedomain/카테고리_내용_Python_v01.py
Normal file
@@ -0,0 +1,14 @@
|
||||
def get_category_and_content(detail_url):
|
||||
res = requests.get(detail_url)
|
||||
soup = BeautifulSoup(res.text, 'html.parser')
|
||||
|
||||
# 카테고리
|
||||
category_tags = soup.select('ul.flex.flex-row.flex-wrap.gap-2 li a')
|
||||
categories = [tag['href'].split('/')[-2] for tag in category_tags]
|
||||
|
||||
# 내용
|
||||
content_div = soup.select_one('div.content-base.workflow-description.text-md')
|
||||
if content_div:
|
||||
content_text = content_div.get_text(separator=' ', strip=True)
|
||||
else:
|
||||
content_text =
|
||||
14
02. Prompts/문서생성/codedomain/커버_슬라이드_Python_v01.py
Normal file
14
02. Prompts/문서생성/codedomain/커버_슬라이드_Python_v01.py
Normal file
@@ -0,0 +1,14 @@
|
||||
def build_ppt(sections, images_map, templates):
|
||||
prs = Presentation()
|
||||
prs.slide_width, prs.slide_height = Inches(8.27), Inches(11.69) # A4
|
||||
|
||||
# 커버 슬라이드
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
tb = slide.shapes.add_textbox(Inches(1), Inches(2), Inches(6.27), Inches(2))
|
||||
p = tb.text_frame.paragraphs[0]
|
||||
p.text = "🚀 자동 보고서"
|
||||
p.font.size = Pt(26); p.font.bold = True
|
||||
|
||||
# 본문 슬라이드
|
||||
for sec in sections:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6]
|
||||
15
02. Prompts/문서생성/codedomain/합계_기준_Python_v01.py
Normal file
15
02. Prompts/문서생성/codedomain/합계_기준_Python_v01.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def find_unit_from_sum_cell(ws, sum_row, max_col):
|
||||
"""
|
||||
합계 셀 기준 단위 탐색
|
||||
- 오른쪽 열 우선, 위쪽 방향 탐색
|
||||
- 대분류 경계 무시 (합계 기준으로만 판단)
|
||||
"""
|
||||
# 오른쪽 열부터 왼쪽으로
|
||||
for c in range(max_col, 0, -1):
|
||||
# 합계 행부터 위쪽으로
|
||||
for r in range(sum_row, 0, -1):
|
||||
cell_val = ws.cell(row=r, column=c).value
|
||||
if is_likely_unit(cell_val):
|
||||
return str(cell_val).strip()
|
||||
|
||||
return ""
|
||||
7
02. Prompts/문서생성/domain/도메인_문서생성_가계_고금리_v01.md
Normal file
7
02. Prompts/문서생성/domain/도메인_문서생성_가계_고금리_v01.md
Normal file
@@ -0,0 +1,7 @@
|
||||
<h3>3-2. 가계: 고금리 피크아웃, 하지만 체감 회복은 느리다</h3>
|
||||
<p>기준금리는 정점을 지나 완만히 낮아지는 방향이지만, 과거 초저금리 시대와 비교하면 여전히 높은 수준이다.</p>
|
||||
<ul>
|
||||
<li>물가는 2022~2023년 고점에 비해 안정되었지만, 체감 물가는 여전히 높다.</li>
|
||||
<li>임금 상승률은 개선되고 있지만, 금리와 물가를 감안한 실질소득 개선 속도는 빠르지 않다.</li>
|
||||
<li>부동산은 급등기와 급락기를 지나 재조정 국면에 있으나, 지역·유형별 격차가 크다.</li>
|
||||
</ul>
|
||||
10
02. Prompts/문서생성/domain/도메인_문서생성_경제_진단_v01.md
Normal file
10
02. Prompts/문서생성/domain/도메인_문서생성_경제_진단_v01.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>경제 진단 보고서</title>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&display=swap');
|
||||
7
02. Prompts/문서생성/domain/도메인_문서생성_구조적_요인_v01.md
Normal file
7
02. Prompts/문서생성/domain/도메인_문서생성_구조적_요인_v01.md
Normal file
@@ -0,0 +1,7 @@
|
||||
<h3>2-4. 구조적 요인: 인구, 생산성, 부동산, 가계부채</h3>
|
||||
<p>한국 경제의 장기 과제를 요약하면 인구, 생산성, 자산, 부채 네 가지 키워드로 정리할 수 있다.</p>
|
||||
<p>첫째, 인구. 한국의 합계출산율은 0.7명 안팎으로 세계 최저 수준이다.</p>
|
||||
<p>둘째, 생산성. 제조업 상위 기업들은 세계 최고 수준의 경쟁력을 유지하고 있지만, 중소기업, 서비스업, 지방 경제의 생산성은 상대적으로 정체되어 있다.</p>
|
||||
<p>셋째, 부동산. 주택 가격은 일부 조정을 거쳤지만, 장기적으로 여전히 소득에 비해 높은 수준이다.</p>
|
||||
<p>넷째, 가계부채. GDP 대비 가계부채 비율은 여전히 주요국 상위권이다.</p>
|
||||
</section>
|
||||
5
02. Prompts/문서생성/domain/도메인_문서생성_굵게_기울임_v01.md
Normal file
5
02. Prompts/문서생성/domain/도메인_문서생성_굵게_기울임_v01.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="shortcut-hint" id="shortcutHint">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>B</kbd> 굵게 | <kbd>Ctrl</kbd>+<kbd>I</kbd> 기울임 | <kbd>Ctrl</kbd>+<kbd>U</kbd> 밑줄</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd> 자간↑ | <kbd>Ctrl</kbd>+<kbd>-</kbd> 자간↓ | <kbd>Ctrl</kbd>+<kbd>S</kbd> 저장</div>
|
||||
<div style="margin-top: 5px; color: #00C853;">💡 본문 <kbd>Enter</kbd> → 새 문단 | <kbd>Backspace</kbd> → 병합</div>
|
||||
</div>
|
||||
6
02. Prompts/문서생성/domain/도메인_문서생성_그림_캡션_v01.md
Normal file
6
02. Prompts/문서생성/domain/도메인_문서생성_그림_캡션_v01.md
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="b-figure"></div>
|
||||
<div class="b-caption">[그림] [캡션]</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
12
02. Prompts/문서생성/domain/도메인_문서생성_그림은_전체폭이며_v01.md
Normal file
12
02. Prompts/문서생성/domain/도메인_문서생성_그림은_전체폭이며_v01.md
Normal file
@@ -0,0 +1,12 @@
|
||||
/* 표, 그림은 전체폭이며 잘리지 않게 */
|
||||
.sheet .body-content table,
|
||||
.sheet .body-content figure{
|
||||
width: 100%;
|
||||
}
|
||||
.sheet .body-content table th{
|
||||
background: var(--b-light) !important;
|
||||
color: var(--b-accent) !important;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
8
02. Prompts/문서생성/domain/도메인_문서생성_글로벌_경제_v01.md
Normal file
8
02. Prompts/문서생성/domain/도메인_문서생성_글로벌_경제_v01.md
Normal file
@@ -0,0 +1,8 @@
|
||||
<section id="global-overview">
|
||||
<h2>1. 글로벌 경제: 위기는 피했지만, 활력도 제한적인 “완만한 성장”</h2>
|
||||
<h3>1-1. 성장률: 3%대 초반의 ‘애매한 회복’</h3>
|
||||
<p>국제통화기금(IMF)은 2025년 세계 경제 성장률을 약 3.0~3.2% 수준으로 전망한다. 2024년 3.3%에서 2025년 3.2%, 2026년 3.1%로 완만하게 둔화되는 그림이다. 선진국은 1.5% 내외, 신흥국은 4%를 조금 웃도는 수준으로 양극화된 성장 구조가 이어질 것으로 평가된다.</p>
|
||||
<p>큰 틀에서 보면 팬데믹 이후 “심각한 글로벌 침체” 시나리오는 피했지만, 세계은행이 지적하듯 향후 몇 년간의 성장률은 글로벌 금융위기 이후 평균보다도 낮은, “장기 저성장 트렌드”에 가까운 모습을 보이고 있다.</p>
|
||||
|
||||
<h3>1-2. 물가와 금리: 인플레이션은 진정, 그러나 금리는 과거보다 높은 수준에서 고착</h3>
|
||||
<
|
||||
6
02. Prompts/문서생성/domain/도메인_문서생성_날짜_호수_v01.md
Normal file
6
02. Prompts/문서생성/domain/도메인_문서생성_날짜_호수_v01.md
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="b-footer">
|
||||
<span>[날짜]</span>
|
||||
<span>[호수]</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
5
02. Prompts/문서생성/domain/도메인_문서생성_대략_색감만_v01.md
Normal file
5
02. Prompts/문서생성/domain/도메인_문서생성_대략_색감만_v01.md
Normal file
@@ -0,0 +1,5 @@
|
||||
/* A, B의 대략 색감만 아주 약하게 */
|
||||
.preview-a4.a .accent{ height:2px; background:#006400; opacity:0.55; margin:4px 0 6px; }
|
||||
.preview-a4.b .accent{ height:2px; background:#e53935; opacity:0.55; margin:4px 0 6px; }
|
||||
</style>
|
||||
</head>
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_대제목_전체폭_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_대제목_전체폭_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
/* 대제목(H1)은 전체폭 */
|
||||
.sheet .body-content .b-top h1,
|
||||
.sheet .body-content .b-col h1{
|
||||
color: var(--b-primary) !important;
|
||||
border-bottom: 2px solid var(--b-primary) !important;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 18pt;
|
||||
font-weight: 900;
|
||||
}
|
||||
3
02. Prompts/문서생성/domain/도메인_문서생성_리드문_리드문_v01.md
Normal file
3
02. Prompts/문서생성/domain/도메인_문서생성_리드문_리드문_v01.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="b-lead">
|
||||
[리드문] [리드문] [리드문]
|
||||
</div>
|
||||
5
02. Prompts/문서생성/domain/도메인_문서생성_리스크_요인_v01.md
Normal file
5
02. Prompts/문서생성/domain/도메인_문서생성_리스크_요인_v01.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<h3>1-3. 리스크 요인: 무역갈등, 지정학, 고부채</h3>
|
||||
<p>글로벌 전망에서 반복적으로 등장하는 키워드는 “정책 불확실성”이다. IMF는 2025년 4·10월 보고서에서, 관세 인상과 공급망 재편, 지정학적 긴장 고조가 향후 성장률을 추가로 깎아먹을 수 있는 하방 리스크라고 지적한다.</p>
|
||||
<p>두 번째 리스크는 고부채다. 코로나 위기 대응 과정에서 확대한 정부지출과 이후의 고금리 환경이 결합되면서 많은 국가의 재정 상태가 빠르게 악화되었다.</p>
|
||||
<p>마지막으로, 디지털 전환과 에너지 전환(탈탄소화)은 장기적으로는 성장 잠재력을 키우는 요인이지만, 단기적으로는 막대한 투자 비용과 산업 구조조정을 수반한다.</p>
|
||||
</section>
|
||||
11
02. Prompts/문서생성/domain/도메인_문서생성_마지막으로_로드한_v01.md
Normal file
11
02. Prompts/문서생성/domain/도메인_문서생성_마지막으로_로드한_v01.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
let isEditable = false;
|
||||
let iframe = null;
|
||||
let currentFileName = "report.html";
|
||||
let currentReportTitle = "Report";
|
||||
let activeBlock = null;
|
||||
const historyStack = [];
|
||||
const redoStack = [];
|
||||
const MAX_HISTORY = 50;
|
||||
let sourceHtml = ""; // 마지막으로 로드한 원본 HTML
|
||||
let droppedAssets = new Map(); // 드롭된 부가 파일들(이미지 등) 이름 -> blob URL
|
||||
3
02. Prompts/문서생성/domain/도메인_문서생성_메시지_처리_v01.md
Normal file
3
02. Prompts/문서생성/domain/도메인_문서생성_메시지_처리_v01.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="toast" id="toast">메시지</div>
|
||||
<div class="loading" id="loading"><div class="spinner"></div><div>처리 중...</div></div>
|
||||
<input type="file" id="fileInput" accept=".html,.htm" onchange="handleFile(event)">
|
||||
10
02. Prompts/문서생성/domain/도메인_문서생성_목차_목차_v01.md
Normal file
10
02. Prompts/문서생성/domain/도메인_문서생성_목차_목차_v01.md
Normal file
@@ -0,0 +1,10 @@
|
||||
<!-- 목차 -->
|
||||
<div class="preview-a4">
|
||||
<div class="pad">
|
||||
<div class="h1">[목차]</div>
|
||||
<div class="toc-l1">1. [대목차]</div>
|
||||
<div class="toc-l2">1.1 [중목차]</div>
|
||||
<div class="toc-l3">1.1.1 [소목차]</div>
|
||||
<div class="toc-l3">1.1.2 [소목차]</div>
|
||||
<div class="toc-l1">2. [대목차]</div>
|
||||
<div class="toc-
|
||||
10
02. Prompts/문서생성/domain/도메인_문서생성_문서_편집기_v01.md
Normal file
10
02. Prompts/문서생성/domain/도메인_문서생성_문서_편집기_v01.md
Normal file
@@ -0,0 +1,10 @@
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">📝 문서 편집기 <span class="logo-sub">Word Style v2</span></div>
|
||||
<button class="btn" onclick="openFile()">📂 파일 열기</button>
|
||||
<button class="btn" onclick="openPasteModal()" style="border-color:#555;">📋 코드 붙여넣기</button>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div style="padding: 8px; font-size: 11px; co
|
||||
3
02. Prompts/문서생성/domain/도메인_문서생성_물가와_금리_v01.md
Normal file
3
02. Prompts/문서생성/domain/도메인_문서생성_물가와_금리_v01.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<h3>2-2. 물가와 금리: 2% 초반 물가, 완화 기조에서 다시 “신중 모드”로</h3>
|
||||
<p>한국의 물가는 2022~2023년 고물가 국면을 지나 2024년 이후 뚜렷한 안정세를 보였고, 2025년에는 2% 안팎에서 움직일 것이라는 전망이 우세하다.</p>
|
||||
<p>한동안 완화 기조로 전환하던 한국은행은 최근 원화 약세와 다시 높아지는 물가 압력을 이유로 기준금리를 2.50% 수준에서 동결하고, “추가 인하에 매우 신중한 입장”으로 한 걸음 물러섰다.</p>
|
||||
6
02. Prompts/문서생성/domain/도메인_문서생성_미분류_과업_v01.md
Normal file
6
02. Prompts/문서생성/domain/도메인_문서생성_미분류_과업_v01.md
Normal file
@@ -0,0 +1,6 @@
|
||||
function cloneMoveChildrenToList(doc, parentEl) {
|
||||
if (!parentEl) return [];
|
||||
const arr = [];
|
||||
Array.from(parentEl.children).forEach(ch => arr.push(ch));
|
||||
return arr;
|
||||
}
|
||||
7
02. Prompts/문서생성/domain/도메인_문서생성_본고딕_맑은_v01.md
Normal file
7
02. Prompts/문서생성/domain/도메인_문서생성_본고딕_맑은_v01.md
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="format-bar" id="formatBar">
|
||||
<select class="format-select" id="fontFamily" onchange="applyFontFamily(this.value); restoreSelection();">
|
||||
<option value="Noto Sans KR">본고딕</option>
|
||||
<option value="Malgun Gothic">맑은 고딕</option>
|
||||
<option value="serif">명조체</option>
|
||||
</select>
|
||||
<input type="number" class="format-select" id="fontSizeInput" value="12" min="6" max="72" style="widt
|
||||
5
02. Prompts/문서생성/domain/도메인_문서생성_본문_다단_v01.md
Normal file
5
02. Prompts/문서생성/domain/도메인_문서생성_본문_다단_v01.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- 본문 1장(다단 인지) -->
|
||||
<div class="preview-a4 b">
|
||||
<div class="pad">
|
||||
<div class="b-section">[대제목]</div>
|
||||
<div class="b-subhead">[소제목]</div>
|
||||
11
02. Prompts/문서생성/domain/도메인_문서생성_본문_대제목_v01.md
Normal file
11
02. Prompts/문서생성/domain/도메인_문서생성_본문_대제목_v01.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<!-- 본문 -->
|
||||
<div class="preview-a4">
|
||||
<div class="pad">
|
||||
<div class="h1">1. [대제목]</div>
|
||||
<div class="h2">1.1 [중제목]</div>
|
||||
<div class="p">[본문]</div>
|
||||
<div class="p muted">[본문]</div>
|
||||
<div class="h2">1.2 [중제목]</div>
|
||||
<div class="p">[본문]</div>
|
||||
<div class="p muted">[본문]</div>
|
||||
|
||||
13
02. Prompts/문서생성/domain/도메인_문서생성_본문_레이아웃_v01.md
Normal file
13
02. Prompts/문서생성/domain/도메인_문서생성_본문_레이아웃_v01.md
Normal file
@@ -0,0 +1,13 @@
|
||||
/* B 본문 레이아웃 */
|
||||
.sheet .body-content .b-top{
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.sheet .body-content .b-columns{
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sheet .body-content .b-col{
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
8
02. Prompts/문서생성/domain/도메인_문서생성_본문_본문_v01.md
Normal file
8
02. Prompts/문서생성/domain/도메인_문서생성_본문_본문_v01.md
Normal file
@@ -0,0 +1,8 @@
|
||||
<div class="b-cols">
|
||||
<div class="b-col">
|
||||
<p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p>
|
||||
</div>
|
||||
<div class="b-col">
|
||||
<p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p>
|
||||
</div>
|
||||
</div>
|
||||
12
02. Prompts/문서생성/domain/도메인_문서생성_소제목은_내부_v01.md
Normal file
12
02. Prompts/문서생성/domain/도메인_문서생성_소제목은_내부_v01.md
Normal file
@@ -0,0 +1,12 @@
|
||||
/* 중, 소제목은 단 내부 */
|
||||
.sheet .body-content .b-col h2{
|
||||
color: var(--b-accent) !important;
|
||||
border-left: 5px solid var(--b-primary) !important;
|
||||
margin: 10px 0 6px 0;
|
||||
font-size: 12pt;
|
||||
font-weight: 900;
|
||||
}
|
||||
.sheet .body-content .b-col h3{
|
||||
color: var(--b-primary) !important;
|
||||
margin: 8px 0 4px 0;
|
||||
|
||||
3
02. Prompts/문서생성/domain/도메인_문서생성_수출과_산업_v01.md
Normal file
3
02. Prompts/문서생성/domain/도메인_문서생성_수출과_산업_v01.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<h3>2-3. 수출과 산업: 반도체와 자동차가 버티는 가운데, 내수는 여전히 약한 고리</h3>
|
||||
<p>2025년 한국 수출은 반도체와 자동차가 이끌고 있다. 특히 메모리 반도체 가격과 수요가 회복되면서, 11월 기준으로 반도체 수출은 전년 동기 대비 20%를 넘는 증가율을 보이고 있고, 자동차 역시 미국·유럽·신흥국에서 견조한 수요를 유지하고 있다.</p>
|
||||
<p>내수 측면에서는 고금리 여파와 실질소득 둔화, 부동산 시장 조정으로 소비 심리가 완전히 회복되지 못했다. 한국은행 소비자동향지수(CCSI)는 팬데믹 직후보다는 높지만, 과거 확장 국면에 비하면 여전히 조심스러운 수준이다.</p>
|
||||
6
02. Prompts/문서생성/domain/도메인_문서생성_양식_목록_v01.md
Normal file
6
02. Prompts/문서생성/domain/도메인_문서생성_양식_목록_v01.md
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="panel-body">
|
||||
<div class="panel-section-title">양식 목록</div>
|
||||
<div class="template-list" id="templateList"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_양식_선택_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_양식_선택_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="viewer">
|
||||
<div id="viewerInner" style="height:100%; width:100%; display:flex; justify-content:center; transform-origin: top center;"></div>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="right-panel" id="templatePanel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">양식 선택</div>
|
||||
<button class="panel-add-btn" type="button" onclick="openTemplateAdd()">양식 추가</button>
|
||||
</div>
|
||||
3
02. Prompts/문서생성/domain/도메인_문서생성_양식_추가는_v01.md
Normal file
3
02. Prompts/문서생성/domain/도메인_문서생성_양식_추가는_v01.md
Normal file
@@ -0,0 +1,3 @@
|
||||
function openTemplateAdd() {
|
||||
toast("양식 추가는 다음 단계에서 연결합니다");
|
||||
}
|
||||
12
02. Prompts/문서생성/domain/도메인_문서생성_에서_검증된_v01.md
Normal file
12
02. Prompts/문서생성/domain/도메인_문서생성_에서_검증된_v01.md
Normal file
@@ -0,0 +1,12 @@
|
||||
// Type A에서 검증된 제목+본문 그룹핑
|
||||
function groupHeadingWithParagraph(nodes, index) {
|
||||
const node = nodes[index];
|
||||
if(!node) return { group: [], consumed: 0 };
|
||||
|
||||
const next = nodes[index + 1];
|
||||
|
||||
if(!node.tagName) return { group: [node], consumed: 1 };
|
||||
|
||||
const tag = node.tagName.toUpperCase();
|
||||
if((tag === 'H2' || tag === 'H3') && next && next.tagName && next.tagName.toUpperCase() === 'P') {
|
||||
return { group: [node
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_요약_박스_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_요약_박스_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
/* 요약 박스 스타일 */
|
||||
header p:last-of-type {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_요약_요약_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_요약_요약_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- 요약 -->
|
||||
<div class="preview-a4">
|
||||
<div class="pad">
|
||||
<div class="h1">[요약]</div>
|
||||
<div class="p">[요약 내용 1]</div>
|
||||
<div class="p">[요약 내용 2]</div>
|
||||
<div class="p">[요약 내용 3]</div>
|
||||
</div>
|
||||
</div>
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_요즘_경제_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_요즘_경제_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="a4-wrapper">
|
||||
|
||||
<header>
|
||||
<h1>요즘 경제, 어디까지 왔나: 2025년 말 글로벌·한국 경제 진단</h1>
|
||||
<p>작성일: 2025년 11월 27일</p>
|
||||
<p>요약: 고물가 쇼크와 급격한 금리 인상 국면이 지나가고 있지만, 세계 경제는 저성장·고부채·지정학 리스크라는 세 가지 부담을 안고 완만한 성장세에 머물고 있다. 한국 경제 역시 1%대 저성장과 수출 의존 구조, 인구·부동산·가계부채 문제라는 고질적 구조적 과제를 동시에 마주하고 있다.</p>
|
||||
</header>
|
||||
6
02. Prompts/문서생성/domain/도메인_문서생성_인쇄_모드_v01.md
Normal file
6
02. Prompts/문서생성/domain/도메인_문서생성_인쇄_모드_v01.md
Normal file
@@ -0,0 +1,6 @@
|
||||
/* --- 인쇄 모드 (PDF 변환 시 적용) 핵심 코드 --- */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0; /* 브라우저 여백 제거하고 body padding으로 제어 */
|
||||
}
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_읽기_전용_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_읽기_전용_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<main class="main">
|
||||
<div class="toolbar">
|
||||
<div class="status-badge" id="editStatusBadge">
|
||||
<div class="dot"></div> <span id="editStatusText">읽기 전용</span>
|
||||
</div>
|
||||
<div class="toolbar-divider"></div>
|
||||
<button class="toolbar-btn" id="btnEdit" onclick="toggleEditMode()">✏️ 편집</button>
|
||||
<button class="toolbar-btn" title="실행 취소" onclick="performUndo()">↩️</button>
|
||||
<button
|
||||
3
02. Prompts/문서생성/domain/도메인_문서생성_자료_출처_v01.md
Normal file
3
02. Prompts/문서생성/domain/도메인_문서생성_자료_출처_v01.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<footer>
|
||||
<p>자료 출처: IMF World Economic Outlook(2025년 4·10월판), World Bank Global Economic Prospects(2025년 6월), OECD Economic Outlook(2025년 6월 및 9월 업데이트), 한국은행, KDI, 국내 주요 언론 경제면 정리.</p>
|
||||
</footer>
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_전문적인_네이비_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_전문적인_네이비_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
:root {
|
||||
--font-main: 'Noto Sans KR', sans-serif;
|
||||
--page-width: 210mm;
|
||||
--page-height: 297mm;
|
||||
--margin-top: 25mm;
|
||||
--margin-bottom: 25mm;
|
||||
--margin-side: 25mm;
|
||||
--primary-color: #003366; /* 전문적인 네이비 컬러 */
|
||||
}
|
||||
4
02. Prompts/문서생성/domain/도메인_문서생성_정리_위기는_v01.md
Normal file
4
02. Prompts/문서생성/domain/도메인_문서생성_정리_위기는_v01.md
Normal file
@@ -0,0 +1,4 @@
|
||||
<h3>3-4. 정리: “위기는 완화, 과제는 심화”된 국면</h3>
|
||||
<p>요약하면, 2025년 말 세계와 한국 경제는 “급한 불은 껐지만, 구조적 과제는 더욱 분명해진 상태”라고 정리할 수 있다.</p>
|
||||
<p>지금의 경제 상황은 “모든 것이 나쁘다”기보다는, “잘하는 영역과 못하는 영역의 차이가 점점 더 크게 벌어지는 시대”에 가깝다.</p>
|
||||
</section>
|
||||
3
02. Prompts/문서생성/domain/도메인_문서생성_제목_강조_v01.md
Normal file
3
02. Prompts/문서생성/domain/도메인_문서생성_제목_강조_v01.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="b-title">
|
||||
[제목] <span class="red">[강조]</span> [제목]
|
||||
</div>
|
||||
10
02. Prompts/문서생성/domain/도메인_문서생성_제목_뒤에서_v01.md
Normal file
10
02. Prompts/문서생성/domain/도메인_문서생성_제목_뒤에서_v01.md
Normal file
@@ -0,0 +1,10 @@
|
||||
h2 {
|
||||
font-size: 16pt;
|
||||
color: #0056b3;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 5px solid #0056b3;
|
||||
padding-left: 10px;
|
||||
break-after: avoid; /* 제목 뒤에서 페이지 넘김 방지 */
|
||||
break-inside: avoid;
|
||||
}
|
||||
4
02. Prompts/문서생성/domain/도메인_문서생성_제목만_덩그러니_v01.md
Normal file
4
02. Prompts/문서생성/domain/도메인_문서생성_제목만_덩그러니_v01.md
Normal file
@@ -0,0 +1,4 @@
|
||||
h2, h3 {
|
||||
break-after: avoid; /* 제목만 덩그러니 남는 것 방지 */
|
||||
page-break-after: avoid;
|
||||
}
|
||||
13
02. Prompts/문서생성/domain/도메인_문서생성_줄바꿈_다음줄_v01.md
Normal file
13
02. Prompts/문서생성/domain/도메인_문서생성_줄바꿈_다음줄_v01.md
Normal file
@@ -0,0 +1,13 @@
|
||||
/* 줄바꿈 시 다음줄 들여쓰기(넘버링 정렬) */
|
||||
.sheet .body-content .b-top h1,
|
||||
.sheet .body-content .b-col h1{
|
||||
padding-left: 24px;
|
||||
text-indent: -24px;
|
||||
}
|
||||
.sheet .body-content .b-col h2{
|
||||
padding-left: 20px;
|
||||
text-indent: -20px;
|
||||
}
|
||||
.sheet .body-content .b-col h3{
|
||||
padding-left: 18px;
|
||||
text-in
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_지금_경제_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_지금_경제_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<section id="implications">
|
||||
<h2>3. 지금 경제 상황이 의미하는 것: 기업과 가계, 투자자의 관점</h2>
|
||||
<h3>3-1. 기업: “고성장 시대”가 아니라 “정교한 선택의 시대”</h3>
|
||||
<p>세계와 한국 모두 고성장 국면은 아니다. 대신, 성장의 편차와 업종 간 차별화가 중요한 시대다.</p>
|
||||
<ul>
|
||||
<li>디지털 전환과 자동화를 통해 인력·자본 효율성을 높이고, 비용 구조를 경직적에서 유연한 형태로 바꾸는 것</li>
|
||||
<li>해외 시장과 공급망 다변화를 통해 특정 국가·산업 의존도를 줄이는 것</li>
|
||||
<li>R&D와 데이터 활용을 통해 제품·서비스 차별화, 고부가가치화를 추구하는 것</li>
|
||||
</ul>
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_타이포그래피_설정_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_타이포그래피_설정_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
/* --- 타이포그래피 설정 --- */
|
||||
h1 {
|
||||
font-size: 24pt;
|
||||
color: var(--primary-color);
|
||||
border-bottom: 3px solid var(--primary-color);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 30px;
|
||||
break-after: avoid; /* 제목 뒤에서 페이지 넘김 방지 */
|
||||
}
|
||||
7
02. Prompts/문서생성/domain/도메인_문서생성_투자자_불확실성_v01.md
Normal file
7
02. Prompts/문서생성/domain/도메인_문서생성_투자자_불확실성_v01.md
Normal file
@@ -0,0 +1,7 @@
|
||||
<h3>3-3. 투자자: “불확실성 관리”가 기본값이 된 시장</h3>
|
||||
<p>주식, 채권, 부동산, 대체투자 모두에서 공통적으로 나타나는 특징은 “중간 정도의 성장과 반복되는 정책 변수”다.</p>
|
||||
<ul>
|
||||
<li>국가·통화·자산군 분산을 통한 리스크 헤지</li>
|
||||
<li>특정 테마(예: AI, 친환경, 헬스케어)에 대한 집중 투자 여부를, 수익·현금흐름·규제 환경을 종합적으로 보며 판단하는 것</li>
|
||||
<li>단기 금리·환율 이벤트에 휘둘리기보다는, 3~5년 이상의 중기적 시계에서 정책과 구조 변화의 방향성을 읽는 것</li>
|
||||
</ul>
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_파일을_놓으세요_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_파일을_놓으세요_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="drop-overlay" id="dropOverlay"><div class="drop-box">📄 파일을 놓으세요</div></div>
|
||||
|
||||
<div class="modal-overlay" id="pasteModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">📋 HTML 코드 붙여넣기</div>
|
||||
<textarea class="modal-textarea" id="pasteInput" placeholder="HTML 코드를 여기에 붙여넣으세요..."></textarea>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" style="width:auto;" onclick="closePasteModal()">취소</button>
|
||||
<bu
|
||||
5
02. Prompts/문서생성/domain/도메인_문서생성_파일을_먼저_v01.md
Normal file
5
02. Prompts/문서생성/domain/도메인_문서생성_파일을_먼저_v01.md
Normal file
@@ -0,0 +1,5 @@
|
||||
function applyTemplate(templateId) {
|
||||
if (!iframe) {
|
||||
toast("파일을 먼저 열어주세요");
|
||||
return;
|
||||
}
|
||||
9
02. Prompts/문서생성/domain/도메인_문서생성_페이지_끝에_v01.md
Normal file
9
02. Prompts/문서생성/domain/도메인_문서생성_페이지_끝에_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
p, ul, li {
|
||||
orphans: 2; /* 페이지 끝에 한 줄만 남는 것 방지 */
|
||||
widows: 2; /* 다음 페이지에 한 줄만 넘어가는 것 방지 */
|
||||
}
|
||||
|
||||
/* 문단이 너무 작게 잘리는 것을 방지 */
|
||||
p {
|
||||
break-inside: auto;
|
||||
}
|
||||
4
02. Prompts/문서생성/domain/도메인_문서생성_페이지_분할_v01.md
Normal file
4
02. Prompts/문서생성/domain/도메인_문서생성_페이지_분할_v01.md
Normal file
@@ -0,0 +1,4 @@
|
||||
/* 페이지 분할 알고리즘 */
|
||||
section {
|
||||
break-inside: auto; /* 섹션 내부에서 페이지 나눔 허용 */
|
||||
}
|
||||
12
02. Prompts/문서생성/domain/도메인_문서생성_표지_날짜_v01.md
Normal file
12
02. Prompts/문서생성/domain/도메인_문서생성_표지_날짜_v01.md
Normal file
@@ -0,0 +1,12 @@
|
||||
function buildPreviewA() {
|
||||
return `
|
||||
<!-- 표지 -->
|
||||
<div class="preview-a4">
|
||||
<div class="pad">
|
||||
<div class="cover-top">[날짜]<br>[작성자]</div>
|
||||
<div class="cover-center">
|
||||
<div class="cover-title">[제목]</div>
|
||||
<div class="cover-sub">[부제]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
02. Prompts/문서생성/domain/도메인_문서생성_표지_목차_v01.md
Normal file
16
02. Prompts/문서생성/domain/도메인_문서생성_표지_목차_v01.md
Normal file
@@ -0,0 +1,16 @@
|
||||
const TEMPLATE_REGISTRY = [
|
||||
{
|
||||
id: "A",
|
||||
name: "A type",
|
||||
desc: "표지 목차 요약 본문 구조",
|
||||
preview: () => buildPreviewA(),
|
||||
apply: (doc) => applyTemplateA(doc),
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
name: "B type",
|
||||
desc: "다단 본문 중심 구조",
|
||||
preview: () => buildPreviewB(),
|
||||
apply: (doc) => applyTemplateB(doc),
|
||||
},
|
||||
];
|
||||
7
02. Prompts/문서생성/domain/도메인_문서생성_표지_특집_v01.md
Normal file
7
02. Prompts/문서생성/domain/도메인_문서생성_표지_특집_v01.md
Normal file
@@ -0,0 +1,7 @@
|
||||
function buildPreviewB() {
|
||||
return `
|
||||
<!-- 표지 1장 -->
|
||||
<div class="preview-a4 b">
|
||||
<div class="pad">
|
||||
<div class="b-tag">[특집]</div>
|
||||
<div class="b-pill">[브랜드]</div>
|
||||
6
02. Prompts/문서생성/domain/도메인_문서생성_표지나_특정_v01.md
Normal file
6
02. Prompts/문서생성/domain/도메인_문서생성_표지나_특정_v01.md
Normal file
@@ -0,0 +1,6 @@
|
||||
/* 표지나 특정 섹션 강제 넘김이 필요하면 사용 */
|
||||
.page-break {
|
||||
break-before: page;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
8
02. Prompts/문서생성/domain/도메인_문서생성_푸터_출처_v01.md
Normal file
8
02. Prompts/문서생성/domain/도메인_문서생성_푸터_출처_v01.md
Normal file
@@ -0,0 +1,8 @@
|
||||
/* 푸터 (출처) 스타일 */
|
||||
footer {
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 10px;
|
||||
font-size: 9pt;
|
||||
color: #888;
|
||||
}
|
||||
5
02. Prompts/문서생성/domain/도메인_문서생성_한국_경제_v01.md
Normal file
5
02. Prompts/문서생성/domain/도메인_문서생성_한국_경제_v01.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<section id="korea-economy">
|
||||
<h2>2. 한국 경제: 1%대 성장과 수출 회복 사이에서 균형 찾기</h2>
|
||||
<h3>2-1. 성장률: 2025년 1% 안팎, 2026년 2%대 회복 전망</h3>
|
||||
<p>OECD는 한국의 2025년 실질 GDP 성장률을 약 1.0% 수준으로, 2026년에는 2.2%까지 완만하게 회복될 것으로 전망한다. 세계 평균 성장률(3%대 초반)과 비교하면 상당히 낮은 수준으로, 저성장 구조가 점점 고착화되고 있다는 평가가 많다.</p>
|
||||
<p>한국은행과 국내 연구기관(KDI 등) 역시 비슷한 그림을 제시한다. 대외 환경이 크게 악화되지는 않았지만, 수출과 설비투자는 제한적 회복에 그치고, 소비와 건설투자가 경제를 강하게 끌어올릴 만큼의 모멘텀을 보여주지 못한다는 판단이다.</p>
|
||||
10
02. Prompts/문서생성/domain/도메인_문서생성_화면_확인용_v01.md
Normal file
10
02. Prompts/문서생성/domain/도메인_문서생성_화면_확인용_v01.md
Normal file
@@ -0,0 +1,10 @@
|
||||
body {
|
||||
font-family: var(--font-main);
|
||||
background-color: #f5f5f5; /* 화면 확인용 회색 배경 */
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
word-break: keep-all; /* 단어 단위 줄바꿈 (가독성 핵심) */
|
||||
text-align: justify;
|
||||
}
|
||||
11
02. Prompts/문서생성/domain/도메인_문서생성_화면에서_처럼_v01.md
Normal file
11
02. Prompts/문서생성/domain/도메인_문서생성_화면에서_처럼_v01.md
Normal file
@@ -0,0 +1,11 @@
|
||||
/* 화면에서 볼 때 A4처럼 보이게 하는 컨테이너 */
|
||||
.a4-wrapper {
|
||||
width: var(--page-width);
|
||||
min-height: var(--page-height);
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
padding: var(--margin-top) var(--margin-side) var(--margin-bottom) var(--margin-side);
|
||||
box-shadow: 0 0 15px rgba(0,0,0,0.1);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
1
02. Prompts/문서생성/exclude/unknown_v01.txt
Normal file
1
02. Prompts/문서생성/exclude/unknown_v01.txt
Normal file
@@ -0,0 +1 @@
|
||||
}
|
||||
5
02. Prompts/문서생성/prompt/GPT_문서생성_다음을_한국어로_v01.md
Normal file
5
02. Prompts/문서생성/prompt/GPT_문서생성_다음을_한국어로_v01.md
Normal file
@@ -0,0 +1,5 @@
|
||||
response = client.chat.completions.create(
|
||||
gpt-4.1-2025-04-14,
|
||||
messages=[{role: user, content: f다음을 한국어로 번역해줘:\n{text}}]
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
9
02. Prompts/문서생성/prompt/GPT_문서생성_미분류_과업_v01.md
Normal file
9
02. Prompts/문서생성/prompt/GPT_문서생성_미분류_과업_v01.md
Normal file
@@ -0,0 +1,9 @@
|
||||
resp = openai.ChatCompletion.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{role:system, content: system_prompt},
|
||||
{role:user, content: user_prompt}
|
||||
],
|
||||
temperature=0.7
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
15
02. Prompts/문서생성/prompt/GPT_문서생성_아래_내용을_v01.md
Normal file
15
02. Prompts/문서생성/prompt/GPT_문서생성_아래_내용을_v01.md
Normal file
@@ -0,0 +1,15 @@
|
||||
response = client.chat.completions.create(
|
||||
gpt-4.1-2025-04-14,
|
||||
messages=[{role: user, content: f아래 내용을 50자 내외로 요약해줘:\n{text}}]
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
|
||||
for category in categories:
|
||||
driver.get(base_url.format(category))
|
||||
time.sleep(2)
|
||||
|
||||
# Load
|
||||
while True:
|
||||
try:
|
||||
load_more = driver.find_element(By.XPATH, //button[contains(text(),'Load more templates')])
|
||||
7
03. Code/geulbeot_10th/.env.sample
Normal file
7
03. Code/geulbeot_10th/.env.sample
Normal file
@@ -0,0 +1,7 @@
|
||||
# 글벗 API Keys
|
||||
# 이 파일을 .env로 복사한 뒤 실제 키값을 입력하세요
|
||||
# cp .env.sample .env
|
||||
|
||||
CLAUDE_API_KEY=여기에_키값_입력
|
||||
GEMINI_API_KEY=여기에_키값_입력
|
||||
GPT_API_KEY=여기에_키값_입력
|
||||
11
03. Code/geulbeot_10th/.gitignore
vendored
Normal file
11
03. Code/geulbeot_10th/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# API Keys - Gitea에 올리지 않기!
|
||||
.env
|
||||
api_keys.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
|
||||
# Output
|
||||
output/
|
||||
@@ -0,0 +1,909 @@
|
||||
너는 다음 분야의 전문가이다: 토목 일반.
|
||||
다음의 도메인 지식을 기반으로, 사실에 근거하여 전문적이고 정확한 내용을 작성하라.
|
||||
추측이나 창작은 금지하며, 제공된 근거 자료의 원문을 최대한 보존하라.
|
||||
|
||||
[도메인 전문 지식]
|
||||
[토목 일반]
|
||||
도레미파솔라시도
|
||||
|
||||
============================================================
|
||||
|
||||
[보고서 작성 가이드]
|
||||
다음 가이드를 참고하여 보고서의 목차 구성과 문체를 결정하라.
|
||||
|
||||
이 문서 묶음은 건설/토목 분야의 측량과 디지털 전환(DX)에 초점을 둔 자료로, 드론(UAV) 사진측량, GIS, 지형·지반(terrain/geotech) 정보 모델의 구축·활용을 다룬다.
|
||||
시공 단계별 측량 절차와 성과품, 기준점 체계, 드론 운용 학습자료, 내부 솔루션(GAIA, GIS Solutions, Terrain Information Model, Map v2.0, GSIM 등)과 워크플로가 포함된다.
|
||||
핵심 키워드: 측량, 드론/UAV, 사진측량, GIS, 디지털 트윈, 지형지반 모델, GAIA, GSIM, 수치지도 2.0, 배수·유역 분석, 성과품·절차.
|
||||
|
||||
너는 건설/토목 측량·드론·GIS·지형지반 모델링 분야의 기술 교육콘텐츠 기획자이자, 자사 솔루션 홍보를 위한 기술 전문가이다.
|
||||
|
||||
나는 이 문서 묶음을 근거로 측량에 대한 기본 개념부터 동향, 건설사업의 디지털 전환(DX)을 위한 측량 변화와 절차, 드론 운용·사진측량 워크플로, GIS/지형지반 모델의 구축·활용과 함께 사례를 정확히 이해하고 적용하고 싶다.
|
||||
우리는 이 자료를 기반으로 CEL(기술발신력 강화) 콘텐츠를 제작하고자 한다.
|
||||
|
||||
목표는 다음과 같다:
|
||||
|
||||
1) 분야에 대한 개념과 관련 기초 개념, 용어에 대한 정의를 명확하게 설명한다.
|
||||
2) 기존 방식에 대한 한계와 분야별 디지털 전환(DX)에 대한 변화, 요구사항 등에 대하여 명확히 설명한다.
|
||||
3) 기존 시장 솔루션들의 방향성과 함께 한계를 최대한 객관적으로 제시한다
|
||||
4) 이러한 문제를 해결하는 방향과 솔루션을 제시하고, 이를 만족하는 자사 솔루션(GAIA, GSIM, Map v2.0 등)의 기술적 강점과 차별점을 자연스럽게 부각한다
|
||||
5) 적용된 사례를 이미지와 함게 이해하기 쉽게 정리한다
|
||||
과업수행 절차와 성과품 기준, 데이터 품질·정합성, 시스템 구성(예: GAIA, GSIM, Map v2.0 등)과 관련된 문제를 분석하여 정리한다.
|
||||
너의 답변은 문서 기반 사실에 한정하고 추측을 금지하며, 가능한 경우 문서명/페이지/그림 참조 등 근거를 명시하라.
|
||||
자료의 공백이나 모호함이 있으면 필요한 가정을 분리해 표시하거나 추가 질문으로 명확히 하라; 외부 일반지식은 참고로만 제시하고 출처 구분을 유지하라.
|
||||
콘텐츠는 내부 직원 교육 및 외부 고객/파트너 대상 기술 세미나에 활용될 예정이므로, 전문성과 신뢰성을 유지하되 이해하기 쉬운 스토리 흐름으로 구성하라.
|
||||
|
||||
이후 청킹, 요약, 용어정의, RAG 검색·인용, 비교표 작성, 분석·보고서 작성, 체크리스트·절차서 도출 등 다양한 작업을 네가 주도적으로 구조화해 지원하라.
|
||||
|
||||
==================================================
|
||||
|
||||
건설·토목 측량 DX 실무지침: 드론/UAV·GIS·지형/지반 모델 기반 전주기 표준과 품질관리
|
||||
|
||||
1. DX 개요와 기본 개념·기준점 체계
|
||||
1.1 측량 DX 프레임과 기초 용어
|
||||
1.1.1 측량 DX 발전 단계
|
||||
- Digitization→Digitalization→DX 정의·사례 | #DX진화 #정책기조 | [인사이트형] | 03 p.62–67 근거 문구 수집, 단계-산출물 매트릭스 표 작성
|
||||
- UAV/3D Mesh/DSM/LiDAR 전환 | #UAV #3D모델 | [기술형] | 03 p.62–68에서 제품유형·데이터모델 비교표와 예시 이미지
|
||||
1.1.2 핵심 용어·원리 정리
|
||||
- GNSS(RTK/VRS/Static)·TS·LiDAR | #측량센싱 | [기술형] | 03 p.64–65,68 용어정의·정확도·용도 표 구성
|
||||
- GSD/DSM/DEM/DTM/TIN·맵핑 vs 모델 | #데이터모델 | [비교형] | 03 p.68 정의/산출물/활용 비교표와 주석
|
||||
1.1.3 수치지형도 2.0 vs 정밀도로지도(HD Map)
|
||||
- 형식·정확도·객체 차이 | #수치지도2.0 #HDMap | [비교형] | 수치지도2.0(SHP 구성) vs HD Map(±0.25m) 비교표(파일·속성·정확도)
|
||||
- SOC 활용 한계·보완 | #활용성 #한계 | [인사이트형] | 정밀도로지도 외측 결손·역설계 필요 사례 정리(매뉴얼 2023.07)
|
||||
1.2 기준점 체계와 국가 수직망 정정
|
||||
1.2.1 기준점 현황·재구축 필요성
|
||||
- 설계기준점 상태 통계 | #기준점점검 | [인사이트형] | 1·2·4공구 정상/망실 수량표·지도 핀맵 작성
|
||||
- 수직망 정정(Z −39~−63mm) 영향 | #수직망정정 | [기술형] | 고시 2023-3084 변화량 표·적용 체크리스트(01/05/08 인용)
|
||||
1.2.2 행정·규정·품질 기준
|
||||
- 공공측량 준용규정·검사기준 | #준용규정 | [절차형] | 서산–명천 문서 내 준용규정 항목 추출, 준수 체크리스트 표
|
||||
- 성과품 품질·미수령 항목 | #품질관리 | [인사이트형] | 01/05/08 미수령 목록 대조표(원본 Pile·정사영상·망조정 등)
|
||||
|
||||
==================================================
|
||||
|
||||
🏛️ A4 보고서 퍼블리싱 마스터 가이드 (v82.0 Intelligent Flow)
|
||||
당신은 **'지능형 퍼블리싱 아키텍트'**입니다. 원본의 **[스타일 독소]**를 제거하고, A4 규격에 맞춰 콘텐츠를 재조립하되, 단순 나열이 아닌 **[최적화된 배치]**를 수행하십시오.
|
||||
텍스트는 **[복사기]**처럼 있는 그대로 보존하고, 레이아웃은 **[강박증]** 수준으로 맞추십시오.
|
||||
|
||||
🚨 0. 최우선 절대 원칙 (Data Integrity)
|
||||
복사기 모드: 원본 텍스트를 절대 요약, 생략(...), 수정하지 마십시오. 무조건 전부 출력하십시오.
|
||||
데이터 무결성: 표의 수치, 본문의 문장은 토씨 하나 바꾸지 않고 보존합니다.
|
||||
|
||||
🚨 1. 핵심 렌더링 원칙 (The 6 Commandments)
|
||||
Deep Sanitization (심층 세탁): 모든 class, style을 삭제하되, 차트/그림 내부의 제목 텍스트는 캡션과 중복되므로 제거하십시오.
|
||||
H1 Only Break: 오직 대목차(H1) 태그에서만 무조건 페이지를 나눕니다.
|
||||
Orphan Control (고아 방지): 중목차(H2), 소목차(H3)가 페이지 하단에 홀로 남을 경우, 통째로 다음 페이지로 넘기십시오.
|
||||
Smart Fit (지능형 맞춤): 표나 그림이 페이지를 넘어가는데 그 양이 적다면(15% 이내), 최대 85%까지 축소하여 현재 페이지에 넣으십시오.
|
||||
Gap Filling (공백 채우기): 그림이 다음 장으로 넘어가 현재 페이지 하단에 큰 공백이 생긴다면, 뒤에 있는 텍스트 문단을 당겨와 그 빈공간을 채우십시오.
|
||||
Visual Standard:
|
||||
여백: 상하좌우 20mm를 시각적으로 고정하십시오.
|
||||
캡션: 모든 그림/표의 제목은 하단 중앙 정렬하십시오.
|
||||
|
||||
|
||||
🛠️ 제작 가이드 (Technical Specs)
|
||||
아래 코드는 렌더링 엔진입니다. 이 구조를 기반으로 사용자 데이터를 raw-container에 주입하여 출력하십시오.
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>A4 Report v83.0 Template</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary: #006400;
|
||||
--accent: #228B22;
|
||||
--light-green: #E8F5E9;
|
||||
--bg: #525659;
|
||||
}
|
||||
body { margin: 0; background: var(--bg); font-family: 'Noto Sans KR', sans-serif; }
|
||||
|
||||
/* [A4 용지 규격] */
|
||||
.sheet {
|
||||
width: 210mm; height: 297mm;
|
||||
background: white; margin: 20px auto;
|
||||
position: relative; overflow: hidden; box-sizing: border-box;
|
||||
box-shadow: 0 0 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
@media print {
|
||||
.sheet { margin: 0; break-after: page; box-shadow: none; }
|
||||
body { background: white; }
|
||||
}
|
||||
|
||||
/* [헤더/푸터: 여백 20mm 영역 내 배치] */
|
||||
.page-header {
|
||||
position: absolute; top: 10mm; left: 20mm; right: 20mm;
|
||||
font-size: 9pt; color: #000000; font-weight: bold;
|
||||
text-align: right; border-bottom: none !important; padding-bottom: 5px;
|
||||
}
|
||||
.page-footer {
|
||||
position: absolute; bottom: 10mm; left: 20mm; right: 20mm;
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
font-size: 9pt; color: #555; border-top: 1px solid #eee; padding-top: 5px;
|
||||
}
|
||||
|
||||
/* [본문 영역: 상하좌우 20mm 고정] */
|
||||
.body-content {
|
||||
position: absolute;
|
||||
top: 20mm; left: 20mm; right: 20mm;
|
||||
bottom: auto; /* 높이는 JS가 제어 */
|
||||
}
|
||||
|
||||
/* [타이포그래피] */
|
||||
h1, h2, h3 {
|
||||
white-space: nowrap; overflow: hidden; word-break: keep-all; color: var(--primary);
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20pt; /* H2와 동일하게 변경 (기존 24pt -> 18pt) */
|
||||
font-weight: 900;
|
||||
color: var(--primary);
|
||||
border-bottom: 2px solid var(--primary);
|
||||
margin-bottom: 20px;
|
||||
margin-top: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18pt;
|
||||
border-left: 5px solid var(--accent);
|
||||
padding-left: 10px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
color: #03581dff;
|
||||
}
|
||||
h3 { font-size: 14pt; margin-top: 20px; margin-bottom: 5px; color: var(--accent); font-weight: 700; }
|
||||
p, li { font-size: 12pt !important; line-height: 1.6 !important; text-align: justify; word-break: keep-all; margin-bottom: 5px; }
|
||||
|
||||
/* [목차 스타일 수정: lvl-1 강조 및 간격 추가] */
|
||||
.toc-item { line-height: 1.8; list-style: none; border-bottom: 1px dotted #eee; }
|
||||
|
||||
.toc-lvl-1 {
|
||||
color: #006400; /* 녹색 */
|
||||
font-weight: 900; /* 볼드 */
|
||||
font-size: 13.5pt; /* lvl-2(10.5pt)보다 3pt 크게 */
|
||||
margin-top: 15px; /* 위쪽 간격 */
|
||||
margin-bottom: 5px; /* 아래쪽 3pt 정도의 간격 */
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
.toc-lvl-2 { font-size: 10.5pt; color: #333; margin-left: 20px; font-weight: normal; }
|
||||
.toc-lvl-3 { font-size: 10.5pt; color: #666; margin-left: 40px; }
|
||||
|
||||
|
||||
/* [표/이미지 스타일] */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
font-size: 9.5pt;
|
||||
table-layout: auto;
|
||||
border-top: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 6px 5px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
/* ▼▼▼ [핵심 수정] 단어 단위 줄바꿈 적용 ▼▼▼ */
|
||||
word-break: keep-all; /* 한글 단어 중간 끊김 방지 (필수) */
|
||||
word-wrap: break-word; /* 아주 긴 영단어는 줄바꿈 허용 (안전장치) */
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--light-green);
|
||||
color: var(--primary);
|
||||
font-weight: 900;
|
||||
white-space: nowrap; /* 제목 셀은 무조건 한 줄 유지 */
|
||||
letter-spacing: -0.05em;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
/* [캡션 및 그림 스타일] */
|
||||
figure { display: block; margin: 20px auto; text-align: center; width: 100%; }
|
||||
img, svg { max-width: 95% !important; height: auto !important; display: block; margin: 0 auto; border: 1px solid #eee; }
|
||||
figcaption {
|
||||
display: block; text-align: center; margin-top: 10px;
|
||||
font-size: 9.5pt; color: #666; font-weight: 600;
|
||||
}
|
||||
|
||||
.atomic-block { break-inside: avoid; page-break-inside: avoid; }
|
||||
#raw-container { display: none; }
|
||||
|
||||
/* [하이라이트 박스 표준] */
|
||||
.highlight-box {
|
||||
background-color: rgb(226, 236, 226);
|
||||
border: 1px solid #2a2c2aff;
|
||||
padding: 5px; margin: 1.5px 1.5px 2px 0px; border-radius: 3px;
|
||||
/* 여기 있는 font-size는 li 태그 때문에 무시됩니다. 아래 코드로 제어하세요. */
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.highlight-box li,
|
||||
.highlight-box p {
|
||||
font-size: 11pt !important; /* 글자 크기 (원하는 대로 수정: 예 9pt, 10pt) */
|
||||
line-height: 1.2; /* 줄 간격 (숫자가 클수록 넓어짐: 예 1.4, 1.6) */
|
||||
letter-spacing: -0.6px; /* 자간 (음수면 좁아짐: 예 -0.5px) */
|
||||
margin-bottom: 3px; /* 항목 간 간격 */
|
||||
color: #1a1919ff; /* 글자 색상 */
|
||||
}
|
||||
|
||||
.highlight-box h3, .highlight-box strong, .highlight-box b {
|
||||
font-size: 12pt !important; color: rgba(2, 37, 2, 1) !important;
|
||||
font-weight: bold; margin: 0; display: block; margin-bottom: 5px;
|
||||
}
|
||||
/* 수정 4 목차 스타일 : 대제목 녹색+크게, 그룹 단위 묶음 */
|
||||
.toc-group {
|
||||
margin-bottom: 12px; /* 기존 간격 유지 */
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* [수정] 점(Bullet) 제거를 위한 핵심 코드 */
|
||||
.toc-lvl-1, .toc-lvl-2, .toc-lvl-3 {
|
||||
list-style: none !important;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
line-height: 1.8;
|
||||
list-style: none; /* 안전장치 */
|
||||
border-bottom: 1px dotted #f3e1e1ff; /* 기존 점선 스타일 유지 */
|
||||
}
|
||||
|
||||
.toc-lvl-1 {
|
||||
color: #006400; /* 기존 녹색 유지 */
|
||||
font-weight: 900;
|
||||
font-size: 13.5pt; /* 기존 폰트 크기 유지 */
|
||||
margin-top: 15px; /* 기존 상단 여백 유지 */
|
||||
margin-bottom: 5px; /* 기존 하단 여백 유지 */
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
.toc-lvl-2 {
|
||||
font-size: 10.5pt;
|
||||
color: #333;
|
||||
margin-left: 20px; /* 기존 들여쓰기 유지 */
|
||||
font-weight: normal;
|
||||
}
|
||||
.toc-lvl-3 {
|
||||
font-size: 10.5pt;
|
||||
color: #666;
|
||||
margin-left: 40px; /* 기존 들여쓰기 유지 */
|
||||
}
|
||||
|
||||
/* [대목차 내부 스타일 보존] */
|
||||
.toc-lvl-1 .toc-number,
|
||||
.toc-lvl-1 .toc-text {
|
||||
font-weight: 900;
|
||||
font-size: 1.2em;
|
||||
color: #006400;
|
||||
}
|
||||
|
||||
.toc-lvl-1 .toc-number {
|
||||
float: left;
|
||||
margin-right: 14px; /* 기존 간격 유지 */
|
||||
}
|
||||
.toc-lvl-1 .toc-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* [소목차 내부 스타일 보존] */
|
||||
.toc-lvl-2 .toc-number, .toc-lvl-3 .toc-number {
|
||||
font-weight: bold;
|
||||
color: #2c5282;
|
||||
margin-right: 11px; /* 기존 간격 유지 */
|
||||
}
|
||||
.toc-lvl-2 .toc-text, .toc-lvl-3 .toc-text {
|
||||
color: #4a5568;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* [요약 페이지 전용 스타일 미세 조정] */
|
||||
.squeeze {
|
||||
line-height: 1.35 !important;
|
||||
letter-spacing: -0.5px !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
.squeeze-title {
|
||||
margin-bottom: 5px !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
|
||||
|
||||
/* 요약 페이지 안의 모든 P 태그에 대해 자간/행간을 좁힘 */
|
||||
#box-summary p,
|
||||
#box-summary li {
|
||||
font-size: 10pt !important; /* 본문보다 0.5pt~1pt 정도 작게 */
|
||||
line-height: 1.45 !important; /* 줄 간격을 조금 더 촘촘하게 (기존 1.6) */
|
||||
letter-spacing: -0.04em !important; /* 자간을 미세하게 좁힘 */
|
||||
margin-bottom: 3px !important; /* 문단 간 격을 줄임 */
|
||||
text-align: justify; /* 양쪽 정렬 유지 */
|
||||
}
|
||||
|
||||
/* 요약 페이지 제목 아래 간격도 조금 줄임 */
|
||||
#box-summary h1 {
|
||||
margin-bottom: 10px !important;
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="raw-container">
|
||||
<div id="box-cover"></div>
|
||||
<div id="box-toc"></div>
|
||||
<div id="box-summary"></div>
|
||||
<div id="box-content"></div>
|
||||
</div>
|
||||
|
||||
<template id="page-template">
|
||||
<div class="sheet">
|
||||
<div class="page-header"></div>
|
||||
<div class="body-content"></div>
|
||||
<div class="page-footer">
|
||||
<span class="rpt-title"></span>
|
||||
<span class="pg-num"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
window.addEventListener("load", async () => {
|
||||
await document.fonts.ready; // 웹폰트 로딩 대기 (필수)
|
||||
// [Config] 297mm - 20mm(상) - 20mm(하) = 257mm ≈ 970px
|
||||
const CONFIG = { maxHeight: 970 };
|
||||
|
||||
const rawContainer = document.getElementById('raw-container');
|
||||
if (rawContainer) {
|
||||
rawContainer.innerHTML = rawContainer.innerHTML.replace(
|
||||
/(<rect[^>]*?)\s+y="[^"]*"\s+([^>]*?y="[^"]*")/gi,
|
||||
"$1 $2"
|
||||
);
|
||||
}
|
||||
const raw = {
|
||||
cover: document.getElementById('box-cover'),
|
||||
toc: document.getElementById('box-toc'),
|
||||
cover: document.getElementById('box-cover'),
|
||||
toc: document.getElementById('box-toc'),
|
||||
summary: document.getElementById('box-summary'),
|
||||
content: document.getElementById('box-content')
|
||||
};
|
||||
|
||||
let globalPage = 1;
|
||||
let reportTitle = raw.cover.querySelector('h1')?.innerText || "Report";
|
||||
|
||||
function cleanH1Text(text) {
|
||||
if (!text) return "";
|
||||
const parts = text.split("-");
|
||||
return parts[0].trim(); // 첫 번째 부분만 남기고 나머지는 버림
|
||||
}
|
||||
|
||||
// [0] Sanitizer & Pre-processing (Integrity Preserved Version)
|
||||
function detox(node) {
|
||||
if (node.nodeType !== 1) return;
|
||||
|
||||
// [Safety Check 1] SVG 내부는 절대 건드리지 않음 (차트 깨짐 방지)
|
||||
if (node.closest('svg')) return;
|
||||
|
||||
// [Logic A] 클래스 속성 확인 및 변수 할당
|
||||
let cls = "";
|
||||
if (node.hasAttribute('class')) {
|
||||
cls = node.getAttribute('class');
|
||||
}
|
||||
|
||||
// [Logic B] 하이라이트 박스 감지 및 변환 (조건 정밀화)
|
||||
// 조건: 1. bg-, border-, box 중 하나라도 포함되어야 함
|
||||
// 2. 단, title-box(제목박스), toc-(목차), cover-(표지)는 절대 아니어야 함
|
||||
if ( (cls.includes('bg-') || cls.includes('border-') || cls.includes('box')) &&
|
||||
!cls.includes('title-box') &&
|
||||
!cls.includes('toc-') &&
|
||||
!cls.includes('cover-') &&
|
||||
!cls.includes('highlight-box') ) { // 이미 변환된 놈도 건드리지 않음
|
||||
|
||||
// 1. 표준 클래스로 강제 교체
|
||||
node.setAttribute('class', 'highlight-box atomic-block');
|
||||
|
||||
// 2. 박스 내부 제목 스타일 초기화 (기존 스타일과의 충돌 방지)
|
||||
const internalHeads = node.querySelectorAll('h3, h4, strong, b');
|
||||
internalHeads.forEach(head => {
|
||||
head.removeAttribute('style');
|
||||
head.removeAttribute('class');
|
||||
});
|
||||
|
||||
// 3. 인라인 스타일 삭제 (Tailwind inline style 등 제거)
|
||||
node.removeAttribute('style');
|
||||
|
||||
// [중요] 여기서 return하면 안됨! 아래 공통 로직(표 테두리 등)도 타야 함.
|
||||
// 대신, class는 이미 세팅했으므로 class 삭제 로직만 건너뛰게 플래그 변경
|
||||
cls = 'highlight-box atomic-block';
|
||||
}
|
||||
|
||||
// [Logic C] 일반 요소 세탁 (화이트리스트 유지)
|
||||
// 목차, 표지, 제목박스, 그리고 방금 변환된 하이라이트 박스는 살려둠
|
||||
if (node.hasAttribute('class')) {
|
||||
// 위에서 cls 변수가 갱신되었을 수 있으므로 다시 확인하지 않고 기존 조건 활용
|
||||
if (!cls.includes('toc-') &&
|
||||
!cls.includes('cover-') &&
|
||||
!cls.includes('highlight-') &&
|
||||
!cls.includes('title-box') &&
|
||||
!cls.includes('atomic-block')) {
|
||||
|
||||
node.removeAttribute('class');
|
||||
}
|
||||
}
|
||||
|
||||
// [Logic D] 공통 정리 (인라인 스타일 삭제)
|
||||
// 단, 이미 변환된 박스는 위에서 지웠으니 중복 실행되어도 상관없음
|
||||
node.removeAttribute('style');
|
||||
|
||||
// [Logic E] 표 테두리 강제 적용
|
||||
if (node.tagName === 'TABLE') node.border = "1";
|
||||
|
||||
// [Logic F] 캡션 중복 텍스트 숨김 처리
|
||||
if (node.tagName === 'FIGURE') {
|
||||
const internalTitles = node.querySelectorAll('h3, h4, .chart-title');
|
||||
internalTitles.forEach(t => t.style.display = 'none');
|
||||
}
|
||||
}
|
||||
|
||||
function getFlatNodes(element) {
|
||||
// [1] 목차(TOC) 처리 로직 (제목 생성 + 완벽한 그룹화)
|
||||
if(element.id === 'box-toc') {
|
||||
// 1. 스타일 초기화
|
||||
element.querySelectorAll('*').forEach(el => detox(el));
|
||||
|
||||
// 2. 레벨 분석 (위의 formatTOC 실행)
|
||||
formatTOC(element);
|
||||
|
||||
const tocNodes = [];
|
||||
|
||||
// [수정] 원본에 H1이 없으면 '목차' 타이틀 강제 생성
|
||||
let title = element.querySelector('h1');
|
||||
if (!title) {
|
||||
title = document.createElement('h1');
|
||||
title.innerText = "목차";
|
||||
// 디자인 통일성을 위해 스타일 적용은 CSS에 맡김
|
||||
}
|
||||
tocNodes.push(title.cloneNode(true));
|
||||
|
||||
// 3. 그룹화 로직 (Flattened List -> Grouped Divs)
|
||||
// 중첩이 엉망인 원본 무시하고, 모든 li를 긁어모음
|
||||
const allLis = element.querySelectorAll('li');
|
||||
let currentGroup = null;
|
||||
|
||||
allLis.forEach(li => {
|
||||
const isLevel1 = li.classList.contains('toc-lvl-1');
|
||||
|
||||
// 대목차(Level 1)가 나오면 새로운 그룹 시작
|
||||
if (isLevel1) {
|
||||
// 이전 그룹이 있으면 저장
|
||||
if (currentGroup) tocNodes.push(currentGroup);
|
||||
|
||||
// 새 그룹 박스 생성
|
||||
currentGroup = document.createElement('div');
|
||||
currentGroup.className = 'toc-group atomic-block';
|
||||
|
||||
// 내부 UL 생성 (들여쓰기 구조용)
|
||||
const ulWrapper = document.createElement('ul');
|
||||
ulWrapper.style.margin = "0";
|
||||
ulWrapper.style.padding = "0";
|
||||
currentGroup.appendChild(ulWrapper);
|
||||
}
|
||||
|
||||
// 안전장치: 첫 시작이 소목차라 그룹이 없으면 하나 만듦
|
||||
if (!currentGroup) {
|
||||
currentGroup = document.createElement('div');
|
||||
currentGroup.className = 'toc-group atomic-block';
|
||||
const ulWrapper = document.createElement('ul');
|
||||
ulWrapper.style.margin = "0";
|
||||
ulWrapper.style.padding = "0";
|
||||
currentGroup.appendChild(ulWrapper);
|
||||
}
|
||||
|
||||
// 현재 그룹의 ul에 li 추가
|
||||
currentGroup.querySelector('ul').appendChild(li.cloneNode(true));
|
||||
});
|
||||
|
||||
// 마지막 그룹 저장
|
||||
if (currentGroup) tocNodes.push(currentGroup);
|
||||
|
||||
return tocNodes;
|
||||
}
|
||||
|
||||
// [2] 본문(Body) 처리 로직 (기존 박스 보존 로직 유지)
|
||||
let nodes = [];
|
||||
Array.from(element.children).forEach(child => {
|
||||
detox(child);
|
||||
|
||||
if (child.classList.contains('highlight-box')) {
|
||||
child.querySelectorAll('h3, h4, strong, b').forEach(head => {
|
||||
head.removeAttribute('style');
|
||||
head.removeAttribute('class');
|
||||
});
|
||||
nodes.push(child.cloneNode(true));
|
||||
}
|
||||
else if(['DIV','SECTION','ARTICLE','MAIN'].includes(child.tagName)) {
|
||||
nodes = nodes.concat(getFlatNodes(child));
|
||||
}
|
||||
else if (['UL','OL'].includes(child.tagName)) {
|
||||
Array.from(child.children).forEach((li, idx) => {
|
||||
detox(li);
|
||||
const w = document.createElement(child.tagName);
|
||||
w.style.margin="0"; w.style.paddingLeft="20px";
|
||||
if(child.tagName==='OL') w.start=idx+1;
|
||||
const cloneLi = li.cloneNode(true);
|
||||
cloneLi.querySelectorAll('*').forEach(el => detox(el));
|
||||
w.appendChild(cloneLi);
|
||||
nodes.push(w);
|
||||
});
|
||||
} else {
|
||||
const clone = child.cloneNode(true);
|
||||
detox(clone);
|
||||
clone.querySelectorAll('*').forEach(el => detox(el));
|
||||
nodes.push(clone);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// [Final Optimized Engine] Place -> Squeeze -> Check -> Split
|
||||
// 목적: 배치 즉시 자간을 줄여 2글자 고아를 방지하고, 공간을 확보하여 페이지 밀림을 막음
|
||||
function renderFlow(sectionType, sourceNodes) {
|
||||
if (!sourceNodes.length) return;
|
||||
|
||||
let currentHeaderTitle = sectionType === 'toc' ? "목차" : (sectionType === 'summary' ? "요약" : reportTitle);
|
||||
|
||||
let page = createPage(sectionType, currentHeaderTitle);
|
||||
let body = page.querySelector('.body-content');
|
||||
|
||||
// 원본 노드 보존을 위해 큐에 담기
|
||||
let queue = [...sourceNodes];
|
||||
|
||||
while (queue.length > 0) {
|
||||
let node = queue.shift();
|
||||
let clone = node.cloneNode(true);
|
||||
|
||||
// [태그 판별]
|
||||
let isH1 = clone.tagName === 'H1';
|
||||
let isHeading = ['H2', 'H3'].includes(clone.tagName);
|
||||
// LI도 텍스트로 취급하여 분할 대상에 포함
|
||||
let isText = ['P', 'LI'].includes(clone.tagName) && !clone.classList.contains('atomic-block');
|
||||
let isAtomic = ['TABLE', 'FIGURE', 'IMG', 'SVG'].includes(clone.tagName) ||
|
||||
clone.querySelector('table, img, svg') ||
|
||||
clone.classList.contains('atomic-block');
|
||||
|
||||
// [전처리] H1 텍스트 정제 ("-" 뒤 제거)
|
||||
if (isH1 && clone.innerText.includes('-')) {
|
||||
clone.innerText = clone.innerText.split('-')[0].trim();
|
||||
}
|
||||
|
||||
// [Rule 1] H1 처리 (무조건 새 페이지)
|
||||
if (isH1 && (sectionType === 'body' || sectionType === 'summary')) {
|
||||
currentHeaderTitle = clone.innerText;
|
||||
if (body.children.length > 0) {
|
||||
page = createPage(sectionType, currentHeaderTitle);
|
||||
body = page.querySelector('.body-content');
|
||||
} else {
|
||||
page.querySelector('.page-header').innerText = currentHeaderTitle;
|
||||
}
|
||||
}
|
||||
|
||||
// [Rule 2] Orphan Control (제목이 페이지 끝에 걸리는 것 방지)
|
||||
if (isHeading) {
|
||||
const spaceLeft = CONFIG.maxHeight - body.scrollHeight;
|
||||
if (spaceLeft < 90) {
|
||||
page = createPage(sectionType, currentHeaderTitle);
|
||||
body = page.querySelector('.body-content');
|
||||
}
|
||||
}
|
||||
|
||||
// ▼▼▼ [Step 1: 일단 배치 (Place)] ▼▼▼
|
||||
body.appendChild(clone);
|
||||
|
||||
// ▼▼▼ [Step 2: 자간 최적화 (Squeeze Logic)] ▼▼▼
|
||||
// 배치 직후, 자간을 줄여서 줄바꿈을 없앨 수 있는지 확인
|
||||
// 대상: 10글자 이상인 텍스트 노드
|
||||
if (isText && clone.innerText.length > 10) {
|
||||
const originalHeight = clone.offsetHeight;
|
||||
|
||||
// 1. 강력하게 줄여봄
|
||||
clone.style.letterSpacing = "-1.0px";
|
||||
|
||||
// 2. 높이가 줄어들었는가? (줄바꿈이 사라짐 = Orphan 해결)
|
||||
if (clone.offsetHeight < originalHeight) {
|
||||
// 성공! 너무 빽빽하지 않게 -0.8px로 안착
|
||||
clone.style.letterSpacing = "-0.8px";
|
||||
} else {
|
||||
// 실패! 줄여도 줄이 안 바뀌면 가독성을 위해 원상복구
|
||||
clone.style.letterSpacing = "";
|
||||
}
|
||||
}
|
||||
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
|
||||
|
||||
// [Rule 3] 넘침 감지 (Overflow Check)
|
||||
if (body.scrollHeight > CONFIG.maxHeight) {
|
||||
|
||||
// 3-1. 텍스트 분할 (Split) - LI 태그 포함
|
||||
if (isText) {
|
||||
body.removeChild(clone); // 일단 제거
|
||||
|
||||
let textContent = node.innerText;
|
||||
let tempP = node.cloneNode(false); // 태그 속성 유지 (li면 li, p면 p)
|
||||
tempP.innerText = "";
|
||||
|
||||
// 위에서 결정된 최적 자간 스타일 유지
|
||||
if (clone.style.letterSpacing) tempP.style.letterSpacing = clone.style.letterSpacing;
|
||||
|
||||
body.appendChild(tempP);
|
||||
|
||||
const words = textContent.split(' ');
|
||||
let currentText = "";
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
let word = words[i];
|
||||
let prevText = currentText;
|
||||
currentText += (currentText ? " " : "") + word;
|
||||
tempP.innerText = currentText;
|
||||
|
||||
// 단어 하나 추가했더니 넘쳤는가?
|
||||
if (body.scrollHeight > CONFIG.maxHeight) {
|
||||
// 직전 상태(안 넘치는 상태)로 복구
|
||||
tempP.innerText = prevText;
|
||||
|
||||
// [디자인 보정] 잘린 문단의 마지막 줄 양쪽 정렬
|
||||
tempP.style.textAlign = "justify";
|
||||
tempP.style.textAlignLast = "justify";
|
||||
|
||||
// 남은 단어들을 다시 합쳐서 대기열 맨 앞으로
|
||||
let remainingText = words.slice(i).join(' ');
|
||||
let remainingNode = node.cloneNode(false);
|
||||
remainingNode.innerText = remainingText;
|
||||
|
||||
queue.unshift(remainingNode);
|
||||
|
||||
// 새 페이지 생성
|
||||
page = createPage(sectionType, currentHeaderTitle);
|
||||
body = page.querySelector('.body-content');
|
||||
|
||||
// [중요] 새 페이지 갔으면 압축 플래그/스타일 초기화
|
||||
// 새 페이지에서는 다시 넉넉하게 시작해야 함
|
||||
body.style.lineHeight = "";
|
||||
body.style.letterSpacing = "";
|
||||
|
||||
break; // for문 탈출
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3-2. 표, 그림, 박스인 경우 -> 통째로 다음 장으로 이동
|
||||
else {
|
||||
body.removeChild(clone); // 일단 뺌
|
||||
|
||||
// [Gap Filling] 빈 공간 채우기
|
||||
let spaceLeft = CONFIG.maxHeight - body.scrollHeight;
|
||||
|
||||
// 공간이 50px 이상 있고, 앞에 글자가 이미 있을 때만 채우기 시도
|
||||
if (body.children.length > 0 && spaceLeft > 50 && queue.length > 0) {
|
||||
while(queue.length > 0) {
|
||||
let candidate = queue[0];
|
||||
if (['H1','H2','H3'].includes(candidate.tagName) ||
|
||||
candidate.classList.contains('atomic-block') ||
|
||||
candidate.querySelector('img, table')) break;
|
||||
|
||||
let filler = candidate.cloneNode(true);
|
||||
|
||||
// 가져올 때도 최적화(Squeeze) 시도
|
||||
if(['P','LI'].includes(filler.tagName) && filler.innerText.length > 10) {
|
||||
const hBefore = filler.offsetHeight; // (가상)
|
||||
filler.style.letterSpacing = "-1.0px";
|
||||
// 실제 DOM에 붙여봐야 높이를 알 수 있으므로 일단 적용
|
||||
}
|
||||
|
||||
body.appendChild(filler);
|
||||
|
||||
if (body.scrollHeight <= CONFIG.maxHeight) {
|
||||
// 들어갔으면 확정하고 대기열 제거
|
||||
// 최적화 스타일 유지 (-1.0px -> -0.8px 조정 등은 생략해도 무방하나 디테일 원하면 추가 가능)
|
||||
if(filler.style.letterSpacing === "-1.0px") filler.style.letterSpacing = "-0.8px";
|
||||
queue.shift();
|
||||
} else {
|
||||
body.removeChild(filler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 이미지 배치 (수정된 핵심 로직)
|
||||
// [버그 수정] 현재 페이지가 비어있지 않을 때만 새 페이지 생성!
|
||||
if (body.children.length > 0) {
|
||||
page = createPage(sectionType, currentHeaderTitle);
|
||||
body = page.querySelector('.body-content');
|
||||
}
|
||||
|
||||
// 이미지를 붙임
|
||||
body.appendChild(clone);
|
||||
|
||||
// [Smart Fit] 넘치면 축소 (기존 유지)
|
||||
if (isAtomic && body.scrollHeight > CONFIG.maxHeight) {
|
||||
const currentH = clone.offsetHeight;
|
||||
const overflow = body.scrollHeight - CONFIG.maxHeight;
|
||||
body.removeChild(clone);
|
||||
|
||||
if (overflow > 0 && overflow < (currentH * 0.15)) {
|
||||
clone.style.transform = "scale(0.85)";
|
||||
clone.style.transformOrigin = "top center";
|
||||
clone.style.marginBottom = `-${currentH * 0.15}px`;
|
||||
body.appendChild(clone);
|
||||
} else {
|
||||
body.appendChild(clone); // 너무 크면 그냥 둠
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPage(type, headerTitle) {
|
||||
const tpl = document.getElementById('page-template');
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
const sheet = clone.querySelector('.sheet');
|
||||
|
||||
if (type === 'cover') {
|
||||
sheet.innerHTML = "";
|
||||
const title = raw.cover.querySelector('h1')?.innerText || "Report";
|
||||
const sub = raw.cover.querySelector('h2')?.innerText || "";
|
||||
const pTags = raw.cover.querySelectorAll('p');
|
||||
const infos = pTags.length > 0 ? Array.from(pTags).map(p => p.innerText).join(" / ") : "";
|
||||
|
||||
// [표지 스타일] 테두리 제거 및 중앙 정렬
|
||||
sheet.innerHTML = `
|
||||
<div style="position:absolute; top:20mm; right:20mm; text-align:right; font-size:11pt; color:#666;">${infos}</div>
|
||||
<div style="display:flex; flex-direction:column; justify-content:center; align-items:center; height:100%; text-align:center; width:100%;">
|
||||
<div style="width:85%;">
|
||||
<div style="font-size:32pt; font-weight:900; color:var(--primary); line-height:1.2; margin-bottom:30px; word-break:keep-all;">${title}</div>
|
||||
<div style="font-size:20pt; font-weight:300; color:#444; word-break:keep-all;">${sub}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
// ... (나머지 페이지 생성 로직 기존 유지) ...
|
||||
clone.querySelector('.page-header').innerText = headerTitle;
|
||||
clone.querySelector('.rpt-title').innerText = reportTitle;
|
||||
if (type !== 'toc') clone.querySelector('.pg-num').innerText = `- ${globalPage++} -`;
|
||||
else clone.querySelector('.pg-num').innerText = "";
|
||||
}
|
||||
document.body.appendChild(sheet);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
createPage('cover');
|
||||
if(raw.toc) renderFlow('toc', getFlatNodes(raw.toc));
|
||||
|
||||
// [요약 페이지 지능형 맞춤 로직 (Smart Squeeze)]
|
||||
const summaryNodes = getFlatNodes(raw.summary);
|
||||
|
||||
// 1. 가상 공간에 미리 렌더링하여 높이 측정
|
||||
const tempBox = document.createElement('div');
|
||||
tempBox.style.width = "210mm";
|
||||
tempBox.style.position = "absolute";
|
||||
tempBox.style.visibility = "hidden";
|
||||
tempBox.id = 'box-summary'; // CSS 적용
|
||||
document.body.appendChild(tempBox);
|
||||
|
||||
// 노드 복제하여 주입
|
||||
summaryNodes.forEach(node => tempBox.appendChild(node.cloneNode(true)));
|
||||
|
||||
// 2. 높이 분석 (Smart Runt Control)
|
||||
const totalHeight = tempBox.scrollHeight;
|
||||
const pageHeight = CONFIG.maxHeight; // 1페이지 가용 높이 (약 970px)
|
||||
const lastPart = totalHeight % pageHeight;
|
||||
|
||||
// [조건] 넘친 양이 100px 미만일 때 압축
|
||||
if (totalHeight > pageHeight && lastPart > 0 && lastPart < 180) {
|
||||
summaryNodes.forEach(node => {
|
||||
if(node.nodeType === 1) {
|
||||
node.classList.add('squeeze');
|
||||
if(node.tagName === 'H1') node.classList.add('squeeze-title');
|
||||
|
||||
// [추가] P, LI 태그에 더 강력한 인라인 스타일 강제 주입 (폰트 축소 포함)
|
||||
if(node.tagName === 'P' || node.tagName === 'LI') {
|
||||
node.style.fontSize = "9.5pt";
|
||||
node.style.lineHeight = "1.4";
|
||||
node.style.letterSpacing = "-0.8px";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// 뒷정리
|
||||
document.body.removeChild(tempBox);
|
||||
// 3. 렌더링 실행
|
||||
renderFlow('summary', summaryNodes);
|
||||
|
||||
// ▼▼▼ [기존 유지] 본문 렌더링 및 마무리 작업 ▼▼▼
|
||||
renderFlow('body', getFlatNodes(raw.content));
|
||||
|
||||
// 긴 제목 자동 축소 (기존 기능 유지)
|
||||
document.querySelectorAll('.sheet h1, .sheet h2').forEach(el => {
|
||||
let fs = 100;
|
||||
while(el.scrollWidth > el.clientWidth && fs > 50) { el.style.fontSize = (--fs)+"%"; }
|
||||
});
|
||||
|
||||
// ▼▼▼▼▼ [수정된 핵심 로직: 통합 자간 조정] ▼▼▼▼▼
|
||||
// 변경점 1: 'li' 태그 포함
|
||||
// 변경점 2: 표, 그림 내부 텍스트 제외
|
||||
// 변경점 3: 글자수 제한 완화 (10자 이상이면 검사)
|
||||
const allTextNodes = document.querySelectorAll('.sheet .body-content p, .sheet .body-content li');
|
||||
|
||||
allTextNodes.forEach(el => {
|
||||
// 1. [제외 대상] 표(table), 그림(figure), 차트 내부는 건드리지 않음
|
||||
if (el.closest('table') || el.closest('figure') || el.closest('.chart')) return;
|
||||
|
||||
// 2. [최소 길이] 10자 미만은 무시
|
||||
if (el.innerText.trim().length < 10) return;
|
||||
|
||||
// 3. [테스트]
|
||||
const originH = el.offsetHeight;
|
||||
const originSpacing = el.style.letterSpacing;
|
||||
el.style.fontSize = "12pt";
|
||||
|
||||
// 강력하게 당겨봄
|
||||
el.style.letterSpacing = "-1.4px";
|
||||
|
||||
const newH = el.offsetHeight;
|
||||
|
||||
// 성공(높이 줄어듦) 시
|
||||
if (newH < originH) {
|
||||
el.style.letterSpacing = "-1.0px"; // 적당히 안착
|
||||
}
|
||||
else {
|
||||
el.style.letterSpacing = originSpacing; // 원상복구
|
||||
}
|
||||
});
|
||||
// ▲▲▲▲▲ [수정 끝] ▲▲▲▲▲
|
||||
|
||||
// 제목 자동 축소 (중복 실행 방지를 위해 제거해도 되지만, 안전하게 둠)
|
||||
document.querySelectorAll('.sheet h1, .sheet h2').forEach(el => {
|
||||
let fs = 100;
|
||||
while(el.scrollWidth > el.clientWidth && fs > 50) { el.style.fontSize = (--fs)+"%"; }
|
||||
});
|
||||
|
||||
const pages = document.querySelectorAll('.sheet'); // .page 대신 .sheet로 수정하여 정확도 높임
|
||||
if (pages.length >= 2) {
|
||||
const lastSheet = pages[pages.length - 1];
|
||||
const prevSheet = pages[pages.length - 2];
|
||||
// 커버나 목차가 아닐때만 진행
|
||||
if(lastSheet.querySelector('.rpt-title')) {
|
||||
const lastBody = lastSheet.querySelector('.body-content');
|
||||
const prevBody = prevSheet.querySelector('.body-content');
|
||||
|
||||
// 마지막 페이지 내용이 3줄(약 150px) 이하인가?
|
||||
if (lastBody.scrollHeight < 150 && lastBody.innerText.trim().length > 0) {
|
||||
prevBody.style.lineHeight = "1.3"; // 앞 페이지 압축
|
||||
prevBody.style.paddingBottom = "0px";
|
||||
|
||||
const contentToMove = Array.from(lastBody.children);
|
||||
contentToMove.forEach(child => prevBody.appendChild(child.cloneNode(true)));
|
||||
|
||||
if (prevBody.scrollHeight <= CONFIG.maxHeight + 5) {
|
||||
lastSheet.remove(); // 성공 시 마지막 장 삭제
|
||||
} else {
|
||||
// 실패 시 원상 복구
|
||||
for(let i=0; i<contentToMove.length; i++) prevBody.lastElementChild.remove();
|
||||
prevBody.style.lineHeight = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 원본 데이터 삭제
|
||||
const rawContainer = document.getElementById('raw-container');
|
||||
if(rawContainer) rawContainer.remove();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
⚠️ [최종 경고 - 출력 직전 필수 확인]
|
||||
1. 원본의 모든 텍스트가 100% 포함되었는가?
|
||||
2. "..." 또는 요약된 문장이 없는가?
|
||||
3. 생략된 문단이 단 하나도 없는가?
|
||||
|
||||
위 3가지 중 하나라도 위반 시, 출력을 중단하고 처음부터 다시 작성하십시오.
|
||||
원본 텍스트 글자 수와 출력 텍스트 글자 수가 동일해야 합니다.
|
||||
1
03. Code/geulbeot_10th/Procfile
Normal file
1
03. Code/geulbeot_10th/Procfile
Normal file
@@ -0,0 +1 @@
|
||||
web: gunicorn app:app
|
||||
453
03. Code/geulbeot_10th/README.md
Normal file
453
03. Code/geulbeot_10th/README.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# 글벗 (Geulbeot) v10.0
|
||||
|
||||
**백엔드 재구조화 + 프론트 모듈화 + 도메인 지식 시스템 + 데모 모드**
|
||||
|
||||
다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤
|
||||
선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다.
|
||||
생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다.
|
||||
|
||||
v10에서는 코드베이스를 전면 재구조화했습니다.
|
||||
백엔드는 handlers를 doc·template 서브패키지로 분리하고,
|
||||
프론트엔드는 3,700줄짜리 index.html을 781줄로 축소하며 JS 9개 모듈로 분리했습니다.
|
||||
토목 14개 세부분야 도메인 지식 시스템과 시연용 데모 모드를 추가했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 아키텍처 (Architecture)
|
||||
|
||||
### 핵심 흐름
|
||||
|
||||
```
|
||||
자료 입력 (파일/폴더)
|
||||
│
|
||||
▼
|
||||
도메인 지식 선택 (v10 신규) ─── 토목 14개 분야 + DX + 보고서 가이드
|
||||
│
|
||||
▼
|
||||
작성 방식 선택 ─── 형식만 변경 / 내용 재구성 / 신규 작성
|
||||
│
|
||||
▼
|
||||
RAG 파이프라인 (9단계) ─── 공통 처리 + 도메인 프롬프트
|
||||
│
|
||||
▼
|
||||
문서 유형 선택
|
||||
├─ 기획서 (기본)
|
||||
├─ 보고서 (기본)
|
||||
├─ 발표자료 (기본)
|
||||
└─ 사용자 등록 (HWPX 분석 → 자동 등록)
|
||||
│
|
||||
▼
|
||||
글벗 표준 HTML 생성 ◀── 템플릿 스타일 + 시맨틱 맵 참조
|
||||
│
|
||||
▼
|
||||
웹 편집기 (수기 편집 / AI 편집)
|
||||
│
|
||||
▼
|
||||
출력 (HTML / PDF / HWP)
|
||||
```
|
||||
|
||||
### 1. Backend (Python Flask)
|
||||
|
||||
- **Language**: Python 3.13
|
||||
- **Web Framework**: Flask 3.0 — 웹 서버 엔진, API 라우팅
|
||||
- **AI**:
|
||||
- Claude API (Anthropic) — 기획서 생성, AI 편집, 문서 유형 맥락 분석
|
||||
- OpenAI API — RAG 임베딩, 인덱싱, 텍스트 추출
|
||||
- Gemini API — 보고서 콘텐츠·HTML 생성
|
||||
- **Features**:
|
||||
- 자료 입력 → 9단계 RAG 파이프라인 + 도메인 프롬프트
|
||||
- 문서 유형별 생성: 기획서 (Claude), 보고서 (Gemini), 사용자 정의 유형
|
||||
- AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`)
|
||||
- 문서 유형 분석·등록: HWPX → 12종 도구 추출 → 시맨틱 매핑 → 스타일 생성 → 유형 CRUD
|
||||
- 도메인 지식 관리 (v10 신규): 토목 14분야 + DX + 보고서 가이드
|
||||
- HWP/PDF 변환
|
||||
|
||||
### 2. Frontend (v10 모듈화)
|
||||
|
||||
- **index.html**: 3,763줄 → **781줄** — HTML 셸만 유지
|
||||
- **main.css**: 1,825줄 — 인라인 CSS 전부 외부 분리
|
||||
- **JS 9개 모듈**:
|
||||
|
||||
| 모듈 | 줄 수 | 역할 |
|
||||
|------|-------|------|
|
||||
| editor.js | 1,208 | 웹 WYSIWYG 편집기 |
|
||||
| doc_type.js | 587 | 문서 유형 선택·CRUD |
|
||||
| generator.js | 483 | 기획서·보고서 생성 호출 |
|
||||
| demo_mode.js | 370 | 시연용 데모 모드 |
|
||||
| domain_selector.js | 287 | 도메인 지식 선택 모달 |
|
||||
| template.js | 188 | 템플릿 관리 UI |
|
||||
| ai_edit.js | 142 | AI 편집 (전체·부분) |
|
||||
| modals.js | 134 | 공통 모달 컴포넌트 |
|
||||
| ui.js | 91 | UI 유틸리티 |
|
||||
| export.js | 71 | HTML/PDF/HWP 다운로드 |
|
||||
|
||||
### 3. 백엔드 패키지 구조 (v10 재구조화)
|
||||
|
||||
```
|
||||
handlers/
|
||||
├── briefing/ 기획서 생성
|
||||
├── report/ 보고서 생성
|
||||
├── doc/ ★ v10 — 문서 유형 서브패키지
|
||||
│ ├── doc_type_analyzer.py AI 맥락·구조 분석
|
||||
│ ├── content_analyzer.py placeholder 분석
|
||||
│ └── custom_doc_type.py 사용자 유형 문서 생성
|
||||
└── template/ ★ v10 — 템플릿 서브패키지
|
||||
├── processor.py 기본 관리
|
||||
├── doc_template_analyzer.py 12종 도구 오케스트레이터
|
||||
├── semantic_mapper.py 요소 의미 판별
|
||||
├── style_generator.py CSS 생성
|
||||
├── template_manager.py CRUD + template.html 조립
|
||||
└── tools/ HWPX 추출 도구 12종
|
||||
```
|
||||
|
||||
### 4. 도메인 지식 시스템 (v10 신규)
|
||||
|
||||
- **domain_api.py** (456줄): 도메인 지식 관리 API + 파이프라인 래퍼
|
||||
- **domain_config.json**: 카테고리 구조 정의 (계층형 선택)
|
||||
- **토목 14개 세부분야**: 측량·해석·교량·터널·도로·구조·지반·시공·공정원가·품질환경·안전·통신·BIM·기획
|
||||
- **DX (디지털 전환)**: 스마트 건설, AI/IoT
|
||||
- **보고서 가이드**: 현안보고서 구조, 작성 가이드
|
||||
- **도메인 선택 UI**: 체크박스 모달 → 선택된 .txt 합쳐서 RAG 파이프라인에 도메인 프롬프트로 전달
|
||||
|
||||
### 5. 데모 모드 (v10 신규)
|
||||
|
||||
- **demo_mode.js**: `DEMO_MODE = true` 시 실제 API 호출 없이 샘플 문서 표시
|
||||
- **샘플 HTML 4종**: 기획서 1p·2p, 보고서, 발표자료
|
||||
- 시연·발표용 — 목차 애니메이션 + 가짜 생성 프로세스
|
||||
|
||||
### 6. 주요 시나리오 (Core Scenarios)
|
||||
|
||||
1. **기획서 생성**: RAG 분석 후 Claude API가 글벗 표준 HTML 생성
|
||||
2. **보고서 생성**: RAG 파이프라인 → Gemini API가 다페이지 HTML 보고서 생성
|
||||
3. **사용자 정의 문서 생성**: 등록된 유형의 template.html 기반 정리·재구성
|
||||
4. **문서 유형 등록**: HWPX 업로드 → 자동 분석 → 유형 CRUD
|
||||
5. **도메인 지식 적용 (v10 신규)**: 분야 선택 → RAG 파이프라인에 전문 용어·기준 주입
|
||||
6. **데모 시연 (v10 신규)**: API 없이 샘플 문서로 전체 워크플로우 시연
|
||||
7. **AI 편집 / HWP 내보내기**
|
||||
|
||||
### 프로세스 플로우
|
||||
|
||||
#### RAG 파이프라인 (공통)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
|
||||
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
|
||||
classDef aiGpt fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
|
||||
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||
|
||||
A[/"📂 자료 입력 (파일/폴더)"/]:::process
|
||||
B["step1: 파일 변환\n모든 형식 → PDF 통일"]:::process
|
||||
C["step2: 텍스트·이미지 추출\n⚡ GPT API"]:::aiGpt
|
||||
D{"분량 판단\n5,000자 기준"}:::decision
|
||||
|
||||
E["step3: 도메인 분석"]:::process
|
||||
F["step4: 의미 단위 청킹"]:::process
|
||||
G["step5: RAG 임베딩 ⚡ GPT"]:::aiGpt
|
||||
H["step6: 코퍼스 생성"]:::process
|
||||
|
||||
I["step7: FAISS 인덱싱 + 목차 ⚡ GPT"]:::aiGpt
|
||||
J(["📋 분석 완료 → 문서 유형 선택"]):::startEnd
|
||||
|
||||
A --> B --> C --> D
|
||||
D -->|"≥ 5,000자"| E --> F --> G --> H --> I
|
||||
D -->|"< 5,000자"| I
|
||||
I --> J
|
||||
```
|
||||
|
||||
#### 전체 워크플로우 (v10 시점)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
|
||||
classDef aiClaude fill:#fff3cd,stroke:#d97706,stroke-width:2px,color:#856404
|
||||
classDef aiGemini fill:#d6eaf8,stroke:#4285f4,stroke-width:2px,color:#1a4d8f
|
||||
classDef editStyle fill:#fff3e0,stroke:#ef6c00,stroke-width:1.5px,color:#e65100
|
||||
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
|
||||
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||
classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
|
||||
classDef newModule fill:#e0f2f1,stroke:#00695c,stroke-width:2px,color:#004d40
|
||||
classDef uiNew fill:#e8eaf6,stroke:#3949ab,stroke-width:2px,color:#1a237e
|
||||
classDef domainStyle fill:#fce4ec,stroke:#c62828,stroke-width:2px,color:#b71c1c
|
||||
|
||||
A(["📂 자료 입력"]):::startEnd
|
||||
|
||||
DOM["🏗️ 도메인 지식 선택\n토목 14분야 + DX\n(v10 신규)"]:::domainStyle
|
||||
|
||||
W{"작성 방식 선택"}:::uiNew
|
||||
W1["📄 형식만 변경"]:::uiNew
|
||||
W2["🔄 내용 재구성"]:::uiNew
|
||||
W3["✨ 신규 작성"]:::uiNew
|
||||
|
||||
R["RAG 파이프라인\n9단계 + 도메인 프롬프트"]:::startEnd
|
||||
|
||||
B{"문서 유형 선택"}:::decision
|
||||
|
||||
C["기획서 생성\n⚡ Claude API"]:::aiClaude
|
||||
D["보고서 생성\n⚡ Gemini API"]:::aiGemini
|
||||
E["발표자료\n예정"]:::planned
|
||||
U["사용자 정의 유형\ntemplate.html 기반"]:::newModule
|
||||
|
||||
T["📋 템플릿 + 시맨틱 맵"]:::newModule
|
||||
|
||||
G["글벗 표준 HTML"]:::startEnd
|
||||
|
||||
H{"편집 방식"}:::decision
|
||||
I["웹 편집기\n수기 편집"]:::editStyle
|
||||
J["AI 편집\n전체·부분 수정\n⚡ Claude API"]:::aiClaude
|
||||
|
||||
K{"출력 형식"}:::decision
|
||||
L["HTML / PDF"]:::exportStyle
|
||||
M["HWP 변환\n하이브리드"]:::exportStyle
|
||||
N["PPT\n예정"]:::planned
|
||||
O(["✅ 최종 산출물"]):::startEnd
|
||||
|
||||
A --> DOM --> W
|
||||
W --> W1 & W2 & W3
|
||||
W1 & W2 & W3 --> R
|
||||
|
||||
DOM -.->|"도메인 프롬프트"| R
|
||||
|
||||
R --> B
|
||||
|
||||
B -->|"기획서"| C --> G
|
||||
B -->|"보고서"| D --> G
|
||||
B -->|"발표자료"| E -.-> G
|
||||
B -->|"사용자 유형"| U --> G
|
||||
|
||||
T -.->|"스타일·구조 참조"| U
|
||||
|
||||
G --> H
|
||||
H -->|"수기"| I --> K
|
||||
H -->|"AI"| J --> K
|
||||
K -->|"웹/인쇄"| L --> O
|
||||
K -->|"HWP"| M --> O
|
||||
K -->|"PPT"| N -.-> O
|
||||
```
|
||||
|
||||
#### 문서 유형 등록
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
|
||||
classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100
|
||||
classDef aiNode fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
|
||||
classDef dataStore fill:#e0f2f1,stroke:#00695c,stroke-width:1.5px,color:#004d40
|
||||
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||
|
||||
A(["📄 HWPX 업로드"]):::startEnd
|
||||
B["DocTemplateAnalyzer\n12종 tools 코드 추출"]:::newModule
|
||||
C["SemanticMapper\n요소 의미 판별\n헤더표/푸터표/제목블록/데이터표"]:::newModule
|
||||
D["StyleGenerator\n추출값 → CSS 생성\ncharPr·paraPr·폰트 매핑"]:::newModule
|
||||
E["ContentAnalyzer\nplaceholder 의미·유형\ncontent_prompt.json"]:::newModule
|
||||
F["DocTypeAnalyzer\n⚡ AI 맥락·구조 분석\nconfig.json"]:::aiNode
|
||||
G["TemplateManager\ntemplate.html 조립"]:::newModule
|
||||
|
||||
H[("📋 templates/user/\ntemplates/{tpl_id}/\ndoc_types/{type_id}/")]:::dataStore
|
||||
|
||||
A --> B --> C --> D --> E
|
||||
B --> F
|
||||
C & D & E & F --> G --> H
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 v9 → v10 변경사항
|
||||
|
||||
| 영역 | v9 | v10 |
|
||||
|------|------|------|
|
||||
| handlers 구조 | 평탄 (루트에 7개 모듈) | **handlers/doc/ + handlers/template/** 서브패키지로 분리 |
|
||||
| 프론트 index.html | 3,763줄 (인라인 CSS·JS) | **781줄** — HTML 셸만 유지 |
|
||||
| CSS | 인라인 | **static/css/main.css** (1,825줄) 외부 분리 |
|
||||
| JS | editor.js 단일 | **9개 모듈** 분리 (doc_type·generator·demo_mode 등) |
|
||||
| 도메인 지식 | 없음 | **domain_api.py** + domain_config.json + 토목 14분야 txt |
|
||||
| 도메인 선택 UI | 없음 | **domain_selector.js** — 체크박스 모달 |
|
||||
| 데모 모드 | 없음 | **demo_mode.js** + 샘플 HTML 4종 |
|
||||
| 보고서 가이드 | 없음 | **domain/report_guide/** — 현안보고서 구조·작성법 |
|
||||
| 레거시 정리 | prompts/ 잔존 | **prompts/ 삭제** |
|
||||
|
||||
---
|
||||
|
||||
## 🗺 상태 및 로드맵 (Status & Roadmap)
|
||||
|
||||
- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현)
|
||||
- **Phase 2**: 문서 생성 — 기획서·보고서·사용자 정의 유형 AI 생성 (🔧 기본 구현)
|
||||
- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현)
|
||||
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현)
|
||||
- **Phase 5**: 문서 유형 분석·등록 — HWPX → 12종 도구 추출 → 시맨틱 매핑 → 유형 CRUD (🔧 기본 구현)
|
||||
- **Phase 6**: HWPX 템플릿 관리 — template_manager, content_order, 독립 저장 (🔧 기본 구현)
|
||||
- **Phase 7**: UI 고도화 — 프론트 모듈화, 데모 모드, 도메인 선택기 (🔧 기본 구현 · 현재 버전)
|
||||
- **Phase 8**: 백엔드 재구조화 — handlers 서브패키지 분리, 레거시 정리 (🔧 기본 구현 · 현재 버전)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작하기 (Getting Started)
|
||||
|
||||
### 사전 요구사항
|
||||
|
||||
- Python 3.10+
|
||||
- Claude API 키 (Anthropic) — 기획서 생성, AI 편집, 문서 유형 분석
|
||||
- OpenAI API 키 — RAG 파이프라인
|
||||
- Gemini API 키 — 보고서 콘텐츠·HTML 생성
|
||||
- pyhwpx — HWP 변환 시 (Windows + 한글 프로그램 필수)
|
||||
|
||||
### 환경 설정
|
||||
|
||||
```bash
|
||||
git clone http://[Gitea주소]/kei/geulbeot-v10.git
|
||||
cd geulbeot-v10
|
||||
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
cp .env.sample .env
|
||||
# .env 파일을 열어 실제 API 키 입력
|
||||
```
|
||||
|
||||
### .env 작성
|
||||
|
||||
```env
|
||||
CLAUDE_API_KEY=sk-ant-your-key-here # 기획서 생성, AI 편집, 유형 분석
|
||||
GPT_API_KEY=sk-proj-your-key-here # RAG 파이프라인
|
||||
GEMINI_API_KEY=AIzaSy-your-key-here # 보고서 콘텐츠 생성
|
||||
```
|
||||
|
||||
### 실행
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
# → http://localhost:5000 접속
|
||||
```
|
||||
|
||||
### 데모 모드
|
||||
|
||||
API 키 없이 시연하려면 `static/js/demo_mode.js`에서 `DEMO_MODE = true` 확인 후 실행.
|
||||
샘플 문서(기획서 2종 + 보고서 + 발표자료)가 자동 표시됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 📂 프로젝트 구조
|
||||
|
||||
```
|
||||
geulbeot_10th/
|
||||
├── app.py # Flask 웹 서버 — API 라우팅
|
||||
├── api_config.py # .env 환경변수 로더
|
||||
├── domain_api.py # ★ v10 — 도메인 지식 관리 API
|
||||
├── domain_config.json # ★ v10 — 도메인 카테고리 구조
|
||||
│
|
||||
├── domain/ # 도메인 지식
|
||||
│ ├── hwpx/ # HWPX 명세서 + 유틸
|
||||
│ ├── civil/ # ★ v10 — 토목 분야
|
||||
│ │ ├── general.txt # 토목 일반
|
||||
│ │ ├── dx.txt # DX (디지털 전환)
|
||||
│ │ └── specialties/ # 14개 세부분야
|
||||
│ │ ├── survey.txt · road.txt · bridge.txt · tunnel.txt
|
||||
│ │ ├── structure.txt · geotechnical.txt · construction.txt
|
||||
│ │ ├── schedule_cost.txt · quality_env.txt · safety.txt
|
||||
│ │ ├── communication.txt · bim.txt · planning.txt
|
||||
│ │ └── anlysis.txt
|
||||
│ └── report_guide/ # ★ v10 — 보고서 작성 가이드
|
||||
│
|
||||
├── handlers/ # 비즈니스 로직 (★ v10 재구조화)
|
||||
│ ├── common.py
|
||||
│ ├── briefing/ # 기획서 처리
|
||||
│ ├── report/ # 보고서 처리
|
||||
│ ├── doc/ # ★ v10 서브패키지 — 문서 유형
|
||||
│ │ ├── doc_type_analyzer.py
|
||||
│ │ ├── content_analyzer.py
|
||||
│ │ └── custom_doc_type.py
|
||||
│ └── template/ # ★ v10 서브패키지 — 템플릿
|
||||
│ ├── processor.py · template_manager.py
|
||||
│ ├── doc_template_analyzer.py · semantic_mapper.py
|
||||
│ ├── style_generator.py
|
||||
│ └── tools/ # HWPX 추출 도구 12종
|
||||
│
|
||||
├── converters/ # 변환 엔진
|
||||
│ ├── pipeline/ # 9단계 RAG 파이프라인
|
||||
│ └── (style_analyzer, hwpx_*, html_to_hwp*)
|
||||
│
|
||||
├── templates/
|
||||
│ ├── default/doc_types/ # 기본 유형 (briefing·report·presentation)
|
||||
│ ├── user/ # 사용자 등록 데이터
|
||||
│ └── index.html # ★ v10 — 781줄 (HTML 셸만)
|
||||
│
|
||||
├── static/ # ★ v10 프론트 모듈화
|
||||
│ ├── css/
|
||||
│ │ ├── main.css # 1,825줄 (인라인 CSS 분리)
|
||||
│ │ └── editor.css # 편집기 스타일
|
||||
│ ├── js/
|
||||
│ │ ├── editor.js # WYSIWYG 편집기
|
||||
│ │ ├── doc_type.js # 문서 유형 선택·CRUD
|
||||
│ │ ├── generator.js # 문서 생성 호출
|
||||
│ │ ├── demo_mode.js # 시연용 데모
|
||||
│ │ ├── domain_selector.js # 도메인 지식 선택
|
||||
│ │ ├── template.js # 템플릿 관리
|
||||
│ │ ├── ai_edit.js # AI 편집
|
||||
│ │ ├── modals.js # 공통 모달
|
||||
│ │ ├── ui.js # UI 유틸리티
|
||||
│ │ └── export.js # 다운로드
|
||||
│ └── result/ # ★ v10 — 데모 샘플 HTML 4종
|
||||
│
|
||||
├── .env / .env.sample
|
||||
├── .gitignore
|
||||
├── Procfile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 글벗 표준 HTML 양식
|
||||
|
||||
| 항목 | 사양 |
|
||||
|------|------|
|
||||
| 용지 | A4 인쇄 최적화 (210mm × 297mm) |
|
||||
| 폰트 | Noto Sans KR (Google Fonts) |
|
||||
| 색상 | Navy 계열 (#1a365d 기본) |
|
||||
| 구성 | page-header → lead-box → section → data-table → bottom-box → page-footer |
|
||||
| 인쇄 | `@media print` 대응, `break-after: page` 페이지 분리 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 알려진 제한사항
|
||||
|
||||
- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완)
|
||||
- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수
|
||||
- 발표자료: config.json만 존재, 실제 생성 미구현
|
||||
- 도메인 지식: 토목 분야만 구축 (타 분야 확장 가능)
|
||||
- 도메인 → RAG 연동: 선택된 도메인 프롬프트 주입 경로 완성 중
|
||||
|
||||
---
|
||||
|
||||
## 📊 코드 규모
|
||||
|
||||
| 영역 | 줄 수 |
|
||||
|------|-------|
|
||||
| Python 전체 | 19,402 (+462) |
|
||||
| 프론트엔드 (JS + CSS + HTML) | 6,463 (+1,196) |
|
||||
| 도메인 지식 (txt) | 1,225 |
|
||||
| **합계** | **~27,100** |
|
||||
|
||||
---
|
||||
|
||||
## 📝 버전 이력
|
||||
|
||||
| 버전 | 핵심 변경 |
|
||||
|------|----------|
|
||||
| v1 | Flask + Claude API 기획서 생성기 |
|
||||
| v2 | 웹 편집기 추가 |
|
||||
| v3 | 9단계 RAG 파이프라인 + HWP 변환 |
|
||||
| v4 | 코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기 |
|
||||
| v5 | HWPX 스타일 주입 + 표 열 너비 정밀 변환 |
|
||||
| v6 | HWPX 템플릿 분석·저장·관리 |
|
||||
| v7 | UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI |
|
||||
| v8 | 문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화 |
|
||||
| v9 | 표 매칭 안정화 + 인라인 아이콘 감지 + 프론트 외부 참조 |
|
||||
| **v10** | **백엔드 재구조화 + 프론트 모듈화 + 도메인 지식 + 데모 모드** |
|
||||
|
||||
---
|
||||
|
||||
## 📝 라이선스
|
||||
|
||||
Private — GPD 내부 사용
|
||||
30
03. Code/geulbeot_10th/api_config.py
Normal file
30
03. Code/geulbeot_10th/api_config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""API 키 관리 - .env 파일에서 읽기"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def load_api_keys():
|
||||
"""프로젝트 폴더의 .env에서 API 키 로딩"""
|
||||
# python-dotenv 있으면 사용
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
env_path = Path(__file__).resolve().parent / '.env'
|
||||
load_dotenv(env_path)
|
||||
except ImportError:
|
||||
# python-dotenv 없으면 수동 파싱
|
||||
env_path = Path(__file__).resolve().parent / '.env'
|
||||
if env_path.exists():
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, _, value = line.partition('=')
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
return {
|
||||
'CLAUDE_API_KEY': os.getenv('CLAUDE_API_KEY', ''),
|
||||
'GPT_API_KEY': os.getenv('GPT_API_KEY', ''),
|
||||
'GEMINI_API_KEY': os.getenv('GEMINI_API_KEY', ''),
|
||||
'PERPLEXITY_API_KEY': os.getenv('PERPLEXITY_API_KEY', ''),
|
||||
}
|
||||
|
||||
API_KEYS = load_api_keys()
|
||||
684
03. Code/geulbeot_10th/app.py
Normal file
684
03. Code/geulbeot_10th/app.py
Normal file
@@ -0,0 +1,684 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
글벗 Light v2.0
|
||||
Flask 라우팅 + 공통 기능
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import tempfile
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, request, jsonify, Response, session, send_file
|
||||
import queue
|
||||
import threading
|
||||
from handlers.template.template_manager import TemplateManager
|
||||
from pathlib import Path
|
||||
from domain_api import register_domain_routes
|
||||
|
||||
# 문서 유형별 프로세서
|
||||
from handlers.template import TemplateProcessor
|
||||
from handlers.briefing import BriefingProcessor
|
||||
from handlers.report import ReportProcessor
|
||||
from handlers.doc.custom_doc_type import CustomDocTypeProcessor
|
||||
from handlers.doc.doc_type_analyzer import DocTypeAnalyzer
|
||||
|
||||
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')
|
||||
register_domain_routes(app)
|
||||
|
||||
# processors 딕셔너리에 추가
|
||||
template_mgr = TemplateManager()
|
||||
processors = {
|
||||
'briefing': BriefingProcessor(),
|
||||
'report': ReportProcessor(),
|
||||
'template': TemplateProcessor(),
|
||||
'custom': CustomDocTypeProcessor()
|
||||
}
|
||||
|
||||
DOC_TYPES_DEFAULT = Path('templates/default/doc_types')
|
||||
DOC_TYPES_USER = Path('templates/user/doc_types')
|
||||
|
||||
|
||||
# ============== 메인 페이지 ==============
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""메인 페이지"""
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/api/doc-types', methods=['GET'])
|
||||
def get_doc_types():
|
||||
"""문서 유형 목록 조회"""
|
||||
try:
|
||||
doc_types = []
|
||||
|
||||
# default 폴더 스캔
|
||||
if DOC_TYPES_DEFAULT.exists():
|
||||
for folder in DOC_TYPES_DEFAULT.iterdir():
|
||||
if folder.is_dir():
|
||||
config_file = folder / 'config.json'
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
doc_types.append(json.load(f))
|
||||
|
||||
# user 폴더 스캔
|
||||
if DOC_TYPES_USER.exists():
|
||||
for folder in DOC_TYPES_USER.iterdir():
|
||||
if folder.is_dir():
|
||||
config_file = folder / 'config.json'
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
doc_types.append(json.load(f))
|
||||
|
||||
# order → isDefault 순 정렬
|
||||
doc_types.sort(key=lambda x: (x.get('order', 999), not x.get('isDefault', False)))
|
||||
|
||||
return jsonify(doc_types)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types', methods=['POST'])
|
||||
def add_doc_type():
|
||||
"""문서 유형 추가 (분석 결과 저장)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'JSON 데이터가 필요합니다'}), 400
|
||||
|
||||
# user 폴더 생성
|
||||
DOC_TYPES_USER.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
type_id = data.get('id')
|
||||
if not type_id:
|
||||
import time
|
||||
type_id = f"user_{int(time.time())}"
|
||||
data['id'] = type_id
|
||||
|
||||
folder_path = DOC_TYPES_USER / type_id
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# config.json 저장
|
||||
with open(folder_path / 'config.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types/<type_id>', methods=['DELETE'])
|
||||
def delete_doc_type(type_id):
|
||||
"""문서 유형 삭제"""
|
||||
try:
|
||||
folder_path = DOC_TYPES_USER / type_id
|
||||
|
||||
if not folder_path.exists():
|
||||
return jsonify({'error': '문서 유형을 찾을 수 없습니다'}), 404
|
||||
|
||||
shutil.rmtree(folder_path)
|
||||
return jsonify({'success': True, 'deleted': type_id})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
# ============== 생성 API ==============
|
||||
|
||||
@app.route('/generate', methods=['POST'])
|
||||
def generate():
|
||||
"""문서 생성 API"""
|
||||
try:
|
||||
content = ""
|
||||
if 'file' in request.files and request.files['file'].filename:
|
||||
file = request.files['file']
|
||||
content = file.read().decode('utf-8')
|
||||
elif 'content' in request.form:
|
||||
content = request.form.get('content', '')
|
||||
|
||||
doc_type = request.form.get('doc_type', 'briefing')
|
||||
|
||||
if doc_type.startswith('user_'):
|
||||
options = {
|
||||
'instruction': request.form.get('instruction', '')
|
||||
}
|
||||
result = processors['custom'].generate(content, doc_type, options)
|
||||
else:
|
||||
options = {
|
||||
'page_option': request.form.get('page_option', '1'),
|
||||
'department': request.form.get('department', ''),
|
||||
'instruction': request.form.get('instruction', '')
|
||||
}
|
||||
|
||||
processor = processors.get(doc_type, processors['briefing'])
|
||||
result = processor.generate(content, options)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400 if 'trace' not in result else 500
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
@app.route('/generate-report', methods=['POST'])
|
||||
def generate_report():
|
||||
"""보고서 생성 API"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
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', ''),
|
||||
'template_id': data.get('template_id')
|
||||
}
|
||||
|
||||
result = processors['report'].generate(content, options)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 500
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
# ============== 수정 API ==============
|
||||
|
||||
@app.route('/refine', methods=['POST'])
|
||||
def refine():
|
||||
"""피드백 반영 API"""
|
||||
try:
|
||||
feedback = request.json.get('feedback', '')
|
||||
current_html = request.json.get('current_html', '') or session.get('current_html', '')
|
||||
original_html = session.get('original_html', '')
|
||||
doc_type = request.json.get('doc_type', 'briefing')
|
||||
|
||||
processor = processors.get(doc_type, processors['briefing'])
|
||||
result = processor.refine(feedback, current_html, original_html)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/refine-selection', methods=['POST'])
|
||||
def refine_selection():
|
||||
"""선택 부분 수정 API"""
|
||||
try:
|
||||
data = request.json
|
||||
current_html = data.get('current_html', '')
|
||||
selected_text = data.get('selected_text', '')
|
||||
user_request = data.get('request', '')
|
||||
doc_type = data.get('doc_type', 'briefing')
|
||||
|
||||
processor = processors.get(doc_type, processors['briefing'])
|
||||
result = processor.refine_selection(current_html, selected_text, user_request)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ============== 다운로드 API ==============
|
||||
|
||||
@app.route('/download/html', methods=['POST'])
|
||||
def download_html():
|
||||
"""HTML 파일 다운로드"""
|
||||
html_content = request.form.get('html', '')
|
||||
if not html_content:
|
||||
return "No content", 400
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'report_{timestamp}.html'
|
||||
|
||||
return Response(
|
||||
html_content,
|
||||
mimetype='text/html',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||
)
|
||||
|
||||
|
||||
@app.route('/download/pdf', methods=['POST'])
|
||||
def download_pdf():
|
||||
"""PDF 파일 다운로드"""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html_content = request.form.get('html', '')
|
||||
if not html_content:
|
||||
return "No content", 400
|
||||
|
||||
pdf_buffer = io.BytesIO()
|
||||
HTML(string=html_content).write_pdf(pdf_buffer)
|
||||
pdf_buffer.seek(0)
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'report_{timestamp}.pdf'
|
||||
|
||||
return Response(
|
||||
pdf_buffer.getvalue(),
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||
)
|
||||
except ImportError:
|
||||
return jsonify({'error': 'PDF 변환 미지원'}), 501
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ============== 기타 API ==============
|
||||
|
||||
@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('/hwp-script')
|
||||
def hwp_script():
|
||||
"""HWP 변환 스크립트 안내"""
|
||||
return render_template('hwp_guide.html')
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
"""헬스 체크"""
|
||||
return jsonify({'status': 'healthy', 'version': '2.0.0'})
|
||||
|
||||
|
||||
@app.route('/export-hwp', methods=['POST'])
|
||||
def export_hwp():
|
||||
"""HWP 변환 (스타일 그루핑 지원)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
html_content = data.get('html', '')
|
||||
doc_type = data.get('doc_type', 'briefing')
|
||||
use_style_grouping = data.get('style_grouping', False) # 새 옵션
|
||||
|
||||
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')
|
||||
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
# 변환기 선택
|
||||
if doc_type == 'briefing':
|
||||
from converters.html_to_hwp_briefing import HtmlToHwpConverter
|
||||
else:
|
||||
from converters.html_to_hwp import HtmlToHwpConverter
|
||||
|
||||
converter = HtmlToHwpConverter(visible=False)
|
||||
|
||||
# 스타일 그루핑 사용 여부
|
||||
if use_style_grouping:
|
||||
final_path = converter.convert_with_styles(html_path, hwp_path)
|
||||
# HWPX 파일 전송
|
||||
return send_file(
|
||||
final_path,
|
||||
as_attachment=True,
|
||||
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwpx',
|
||||
mimetype='application/vnd.hancom.hwpx'
|
||||
)
|
||||
else:
|
||||
converter.convert(html_path, hwp_path)
|
||||
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
|
||||
|
||||
|
||||
# 기존 add_doc_type 대체 또는 수정
|
||||
@app.route('/api/doc-types/analyze', methods=['POST'])
|
||||
def analyze_doc_type():
|
||||
"""문서 유형 분석 API"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "파일이 필요합니다"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
doc_name = request.form.get('name', '새 문서 유형')
|
||||
|
||||
# 임시 저장
|
||||
import tempfile
|
||||
temp_path = os.path.join(tempfile.gettempdir(), file.filename)
|
||||
file.save(temp_path)
|
||||
|
||||
try:
|
||||
analyzer = DocTypeAnalyzer()
|
||||
result = analyzer.analyze(temp_path, doc_name)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"config": result["config"],
|
||||
"summary": {
|
||||
"pageCount": result["structure"]["pageCount"],
|
||||
"sections": len(result["toc"]),
|
||||
"style": result["style"]
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
@app.route('/analyze-styles', methods=['POST'])
|
||||
def analyze_styles():
|
||||
"""HTML 스타일 분석 미리보기"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
html_content = data.get('html', '')
|
||||
|
||||
if not html_content:
|
||||
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
|
||||
|
||||
from converters.style_analyzer import StyleAnalyzer
|
||||
from converters.hwp_style_mapping import ROLE_TO_STYLE_NAME
|
||||
|
||||
analyzer = StyleAnalyzer()
|
||||
elements = analyzer.analyze(html_content)
|
||||
|
||||
# 요약 정보
|
||||
summary = analyzer.get_role_summary()
|
||||
|
||||
# 상세 정보 (처음 50개만)
|
||||
details = []
|
||||
for elem in elements[:50]:
|
||||
details.append({
|
||||
'role': elem.role,
|
||||
'hwp_style': ROLE_TO_STYLE_NAME.get(elem.role, '바탕글'),
|
||||
'text': elem.text[:50] + ('...' if len(elem.text) > 50 else ''),
|
||||
'section': elem.section
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total_elements': len(elements),
|
||||
'summary': summary,
|
||||
'details': details
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
@app.route('/templates', methods=['GET'])
|
||||
def get_templates():
|
||||
"""저장된 템플릿 목록 조회"""
|
||||
try:
|
||||
templates = template_mgr.list_templates()
|
||||
return jsonify(templates)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/templates', methods=['GET'])
|
||||
def get_templates_api():
|
||||
"""템플릿 목록 조회 (API 경로)"""
|
||||
try:
|
||||
templates = template_mgr.list_templates()
|
||||
return jsonify(templates)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/analyze-template', methods=['POST'])
|
||||
def analyze_template():
|
||||
"""템플릿 추출 및 저장 (doc_template_analyzer → template_manager)"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': '파일이 없습니다'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
name = request.form.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': '템플릿 이름을 입력해주세요'}), 400
|
||||
|
||||
if not file.filename:
|
||||
return jsonify({'error': '파일을 선택해주세요'}), 400
|
||||
|
||||
# 임시 저장 → HWPX 파싱 → 템플릿 추출
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_path = os.path.join(temp_dir, file.filename)
|
||||
file.save(temp_path)
|
||||
|
||||
try:
|
||||
# v3 파서 재사용 (HWPX → parsed dict)
|
||||
from handlers.doc.doc_type_analyzer import DocTypeAnalyzer
|
||||
parser = DocTypeAnalyzer()
|
||||
parsed = parser._parse_hwpx(temp_path)
|
||||
|
||||
# template_manager로 추출+저장
|
||||
result = template_mgr.extract_and_save(
|
||||
parsed, name,
|
||||
source_file=file.filename
|
||||
)
|
||||
|
||||
return jsonify(result)
|
||||
finally:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
# ============== 문서 유형 분석 SSE API ==============
|
||||
|
||||
@app.route('/api/doc-types/analyze-stream', methods=['POST'])
|
||||
def analyze_doc_type_stream():
|
||||
"""
|
||||
문서 유형 분석 (SSE 스트리밍)
|
||||
실시간으로 각 단계의 진행 상황을 전달
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
# 파일 및 데이터 검증
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': '파일이 없습니다'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': '문서 유형 이름을 입력해주세요'}), 400
|
||||
|
||||
if not file.filename:
|
||||
return jsonify({'error': '파일을 선택해주세요'}), 400
|
||||
|
||||
# 임시 파일 저장
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_path = os.path.join(temp_dir, file.filename)
|
||||
file.save(temp_path)
|
||||
|
||||
# 메시지 큐 생성
|
||||
message_queue = queue.Queue()
|
||||
analysis_result = {"data": None, "error": None}
|
||||
|
||||
def progress_callback(step_id, status, message):
|
||||
"""진행 상황 콜백 - 메시지 큐에 추가"""
|
||||
message_queue.put({
|
||||
"type": "progress",
|
||||
"step": step_id,
|
||||
"status": status,
|
||||
"message": message
|
||||
})
|
||||
|
||||
def run_analysis():
|
||||
"""분석 실행 (별도 스레드)"""
|
||||
try:
|
||||
|
||||
analyzer = DocTypeAnalyzer(progress_callback=progress_callback)
|
||||
result = analyzer.analyze(temp_path, name, description)
|
||||
|
||||
# 저장
|
||||
save_path = analyzer.save_doc_type(result["config"], result.get("template", "") )
|
||||
|
||||
analysis_result["data"] = {
|
||||
"success": True,
|
||||
"config": result["config"],
|
||||
"layout": result.get("layout", {}),
|
||||
"context": result.get("context", {}),
|
||||
"structure": result.get("structure", {}),
|
||||
"template_generated": bool(result.get("template_id") or result.get("template")),
|
||||
"template_id": result.get("template_id"), # ★ 추가
|
||||
"saved_path": save_path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
analysis_result["error"] = {
|
||||
"message": str(e),
|
||||
"trace": traceback.format_exc()
|
||||
}
|
||||
finally:
|
||||
# 완료 신호
|
||||
message_queue.put({"type": "complete"})
|
||||
# 임시 파일 삭제
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def generate_events():
|
||||
"""SSE 이벤트 생성기"""
|
||||
# 분석 시작
|
||||
analysis_thread = threading.Thread(target=run_analysis)
|
||||
analysis_thread.start()
|
||||
|
||||
# 이벤트 스트리밍
|
||||
while True:
|
||||
try:
|
||||
msg = message_queue.get(timeout=60) # 60초 타임아웃
|
||||
|
||||
if msg["type"] == "complete":
|
||||
# 분석 완료
|
||||
if analysis_result["error"]:
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': analysis_result['error']}, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'result', 'data': analysis_result['data']}, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
else:
|
||||
# 진행 상황
|
||||
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
|
||||
|
||||
except queue.Empty:
|
||||
# 타임아웃
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': {'message': '분석 시간 초과'}}, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
|
||||
return Response(
|
||||
generate_events(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
||||
|
||||
@app.route('/delete-template/<template_id>', methods=['DELETE'])
|
||||
def delete_template(template_id):
|
||||
"""템플릿 삭제 (레거시 호환)"""
|
||||
try:
|
||||
result = template_mgr.delete_template(template_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/templates/<tpl_id>', methods=['GET'])
|
||||
def get_template(tpl_id):
|
||||
"""특정 템플릿 조회"""
|
||||
try:
|
||||
result = template_mgr.load_template(tpl_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 404
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/templates/<tpl_id>', methods=['DELETE'])
|
||||
def delete_template_new(tpl_id):
|
||||
"""템플릿 삭제"""
|
||||
try:
|
||||
result = template_mgr.delete_template(tpl_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types/<type_id>/template', methods=['PUT'])
|
||||
def change_doc_type_template(type_id):
|
||||
"""문서 유형의 템플릿 교체"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
new_tpl_id = data.get('template_id')
|
||||
|
||||
if not new_tpl_id:
|
||||
return jsonify({'error': 'template_id가 필요합니다'}), 400
|
||||
|
||||
result = template_mgr.change_template(type_id, new_tpl_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types/<type_id>/template', methods=['GET'])
|
||||
def get_doc_type_template(type_id):
|
||||
"""문서 유형에 연결된 템플릿 조회"""
|
||||
try:
|
||||
result = template_mgr.get_template_for_doctype(type_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 404
|
||||
return jsonify(result)
|
||||
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'
|
||||
app.run(host='0.0.0.0', port=port, debug=debug)
|
||||
0
03. Code/geulbeot_10th/converters/__init__.py
Normal file
0
03. Code/geulbeot_10th/converters/__init__.py
Normal file
1115
03. Code/geulbeot_10th/converters/html_to_hwp.py
Normal file
1115
03. Code/geulbeot_10th/converters/html_to_hwp.py
Normal file
File diff suppressed because it is too large
Load Diff
616
03. Code/geulbeot_10th/converters/html_to_hwp_briefing.py
Normal file
616
03. Code/geulbeot_10th/converters/html_to_hwp_briefing.py
Normal file
@@ -0,0 +1,616 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HTML → HWP 변환기 (기획서 전용)
|
||||
|
||||
✅ 머리말/꼬리말: 보고서 방식 적용 (페이지 번호 포함)
|
||||
✅ lead-box, section, data-table, strategy-grid, qa-grid, bottom-box 지원
|
||||
✅ process-container (단계별 프로세스) 지원
|
||||
✅ badge 스타일 텍스트 변환
|
||||
✅ Navy 색상 테마
|
||||
|
||||
pip install pyhwpx beautifulsoup4
|
||||
"""
|
||||
|
||||
from pyhwpx import Hwp
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
"""페이지 설정"""
|
||||
PAGE_WIDTH = 210
|
||||
PAGE_HEIGHT = 297
|
||||
MARGIN_LEFT = 20
|
||||
MARGIN_RIGHT = 20
|
||||
MARGIN_TOP = 20
|
||||
MARGIN_BOTTOM = 15
|
||||
HEADER_LEN = 10
|
||||
FOOTER_LEN = 10
|
||||
CONTENT_WIDTH = 170
|
||||
|
||||
|
||||
class HtmlToHwpConverter:
|
||||
"""HTML → HWP 변환기 (기획서 전용)"""
|
||||
|
||||
def __init__(self, visible=True):
|
||||
self.hwp = Hwp(visible=visible)
|
||||
self.cfg = Config()
|
||||
self.colors = {}
|
||||
self.is_first_h1 = True
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 초기화 및 유틸리티
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def _init_colors(self):
|
||||
"""색상 팔레트 초기화 (Navy 계열)"""
|
||||
self.colors = {
|
||||
'primary-navy': self.hwp.RGBColor(26, 54, 93), # #1a365d
|
||||
'secondary-navy': self.hwp.RGBColor(44, 82, 130), # #2c5282
|
||||
'accent-navy': self.hwp.RGBColor(49, 130, 206), # #3182ce
|
||||
'dark-gray': self.hwp.RGBColor(45, 55, 72), # #2d3748
|
||||
'medium-gray': self.hwp.RGBColor(74, 85, 104), # #4a5568
|
||||
'light-gray': self.hwp.RGBColor(226, 232, 240), # #e2e8f0
|
||||
'bg-light': self.hwp.RGBColor(247, 250, 252), # #f7fafc
|
||||
'border-color': self.hwp.RGBColor(203, 213, 224), # #cbd5e0
|
||||
'badge-safe': self.hwp.RGBColor(30, 111, 63), # #1e6f3f
|
||||
'badge-caution': self.hwp.RGBColor(154, 91, 19), # #9a5b13
|
||||
'badge-risk': self.hwp.RGBColor(161, 43, 43), # #a12b2b
|
||||
'white': self.hwp.RGBColor(255, 255, 255),
|
||||
'black': self.hwp.RGBColor(0, 0, 0),
|
||||
}
|
||||
|
||||
def _mm(self, mm):
|
||||
"""밀리미터를 HWP 단위로 변환"""
|
||||
return self.hwp.MiliToHwpUnit(mm)
|
||||
|
||||
def _pt(self, pt):
|
||||
"""포인트를 HWP 단위로 변환"""
|
||||
return self.hwp.PointToHwpUnit(pt)
|
||||
|
||||
def _rgb(self, hex_color):
|
||||
"""HEX 색상을 RGB로 변환"""
|
||||
c = hex_color.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 _font(self, size=10, color='black', bold=False):
|
||||
"""폰트 설정 (색상 이름 사용)"""
|
||||
self.hwp.set_font(
|
||||
FaceName='맑은 고딕',
|
||||
Height=size,
|
||||
Bold=bold,
|
||||
TextColor=self.colors.get(color, self.colors['black'])
|
||||
)
|
||||
|
||||
def _set_font(self, size=11, bold=False, hex_color='#000000'):
|
||||
"""폰트 설정 (HEX 색상 사용)"""
|
||||
self.hwp.set_font(
|
||||
FaceName='맑은 고딕',
|
||||
Height=size,
|
||||
Bold=bold,
|
||||
TextColor=self._rgb(hex_color)
|
||||
)
|
||||
|
||||
def _align(self, align):
|
||||
"""정렬 설정"""
|
||||
actions = {
|
||||
'left': 'ParagraphShapeAlignLeft',
|
||||
'center': 'ParagraphShapeAlignCenter',
|
||||
'right': 'ParagraphShapeAlignRight',
|
||||
'justify': 'ParagraphShapeAlignJustify',
|
||||
}
|
||||
if align in actions:
|
||||
self.hwp.HAction.Run(actions[align])
|
||||
|
||||
def _para(self, text='', size=10, color='black', bold=False, align='left'):
|
||||
"""문단 삽입"""
|
||||
self._align(align)
|
||||
self._font(size, color, bold)
|
||||
if text:
|
||||
self.hwp.insert_text(text)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _exit_table(self):
|
||||
"""표 편집 모드 종료"""
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
self.hwp.BreakPara()
|
||||
|
||||
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)
|
||||
print(f"[설정] 여백: 좌우 {self.cfg.MARGIN_LEFT}mm, 상 {self.cfg.MARGIN_TOP}mm, 하 {self.cfg.MARGIN_BOTTOM}mm")
|
||||
except Exception as e:
|
||||
print(f"[경고] 페이지 설정 실패: {e}")
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 머리말 / 꼬리말 (보고서 방식)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
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, '#4a5568')
|
||||
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, '#4a5568')
|
||||
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, '#4a5568')
|
||||
self.hwp.insert_text(header_text)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 구역 머리말: {e}")
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 셀 배경색 설정
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def _set_cell_bg(self, color_name):
|
||||
"""셀 배경색 설정 (색상 이름)"""
|
||||
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
pset = self.hwp.HParameterSet.HCellBorderFill
|
||||
pset.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
|
||||
pset.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
|
||||
pset.FillAttr.WinBrushHatchColor = self.hwp.RGBColor(0, 0, 0)
|
||||
pset.FillAttr.WinBrushFaceColor = self.colors.get(color_name, self.colors['white'])
|
||||
pset.FillAttr.WindowsBrush = 1
|
||||
self.hwp.HAction.Execute("CellBorderFill", pset.HSet)
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# HTML 요소 변환 (기획서 전용)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def _convert_lead_box(self, elem):
|
||||
"""lead-box 변환 (핵심 기조 박스)"""
|
||||
content = elem.find("div")
|
||||
if not content:
|
||||
return
|
||||
|
||||
text = content.get_text(strip=True)
|
||||
text = ' '.join(text.split())
|
||||
print(f" → lead-box")
|
||||
|
||||
self.hwp.create_table(1, 1, treat_as_char=True)
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(11.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(text)
|
||||
self._exit_table()
|
||||
|
||||
def _convert_strategy_grid(self, elem):
|
||||
"""strategy-grid 변환 (2x2 전략 박스)"""
|
||||
items = elem.find_all(class_="strategy-item")
|
||||
if not items:
|
||||
return
|
||||
|
||||
print(f" → strategy-grid: {len(items)} items")
|
||||
|
||||
self.hwp.create_table(2, 2, treat_as_char=True)
|
||||
|
||||
for i, item in enumerate(items[:4]):
|
||||
if i > 0:
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self._set_cell_bg('bg-light')
|
||||
|
||||
title = item.find(class_="strategy-title")
|
||||
if title:
|
||||
self._font(10, 'primary-navy', True)
|
||||
self.hwp.insert_text(title.get_text(strip=True))
|
||||
self.hwp.BreakPara()
|
||||
|
||||
p = item.find("p")
|
||||
if p:
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(p.get_text(strip=True))
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_process_container(self, elem):
|
||||
"""process-container 변환 (단계별 프로세스)"""
|
||||
steps = elem.find_all(class_="process-step")
|
||||
if not steps:
|
||||
return
|
||||
|
||||
print(f" → process-container: {len(steps)} steps")
|
||||
|
||||
rows = len(steps)
|
||||
self.hwp.create_table(rows, 2, treat_as_char=True)
|
||||
|
||||
for i, step in enumerate(steps):
|
||||
if i > 0:
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# 번호 셀
|
||||
num = step.find(class_="step-num")
|
||||
self._set_cell_bg('primary-navy')
|
||||
self._font(10, 'white', True)
|
||||
self._align('center')
|
||||
if num:
|
||||
self.hwp.insert_text(num.get_text(strip=True))
|
||||
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# 내용 셀
|
||||
content = step.find(class_="step-content")
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(10.5, 'dark-gray', False)
|
||||
self._align('left')
|
||||
if content:
|
||||
self.hwp.insert_text(content.get_text(strip=True))
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_data_table(self, table):
|
||||
"""data-table 변환 (badge 포함)"""
|
||||
data = []
|
||||
|
||||
thead = table.find("thead")
|
||||
if thead:
|
||||
ths = thead.find_all("th")
|
||||
data.append([th.get_text(strip=True) for th in ths])
|
||||
|
||||
tbody = table.find("tbody")
|
||||
if tbody:
|
||||
for tr in tbody.find_all("tr"):
|
||||
row = []
|
||||
for td in tr.find_all("td"):
|
||||
badge = td.find(class_="badge")
|
||||
if badge:
|
||||
badge_class = ' '.join(badge.get('class', []))
|
||||
badge_text = badge.get_text(strip=True)
|
||||
if 'badge-safe' in badge_class:
|
||||
row.append(f"[✓ {badge_text}]")
|
||||
elif 'badge-caution' in badge_class:
|
||||
row.append(f"[△ {badge_text}]")
|
||||
elif 'badge-risk' in badge_class:
|
||||
row.append(f"[✗ {badge_text}]")
|
||||
else:
|
||||
row.append(f"[{badge_text}]")
|
||||
else:
|
||||
row.append(td.get_text(strip=True))
|
||||
data.append(row)
|
||||
|
||||
if not data:
|
||||
return
|
||||
|
||||
rows = len(data)
|
||||
cols = len(data[0]) if data else 0
|
||||
print(f" → data-table: {rows}×{cols}")
|
||||
|
||||
self.hwp.create_table(rows, cols, treat_as_char=True)
|
||||
|
||||
for row_idx, row in enumerate(data):
|
||||
for col_idx, cell_text in enumerate(row):
|
||||
is_header = (row_idx == 0)
|
||||
is_first_col = (col_idx == 0 and not is_header)
|
||||
|
||||
is_safe = '[✓' in str(cell_text)
|
||||
is_caution = '[△' in str(cell_text)
|
||||
is_risk = '[✗' in str(cell_text)
|
||||
|
||||
if is_header:
|
||||
self._set_cell_bg('primary-navy')
|
||||
self._font(9, 'white', True)
|
||||
elif is_first_col:
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(9.5, 'primary-navy', True)
|
||||
elif is_safe:
|
||||
self._font(9.5, 'badge-safe', True)
|
||||
elif is_caution:
|
||||
self._font(9.5, 'badge-caution', True)
|
||||
elif is_risk:
|
||||
self._font(9.5, 'badge-risk', True)
|
||||
else:
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
|
||||
self._align('center')
|
||||
self.hwp.insert_text(str(cell_text))
|
||||
|
||||
if not (row_idx == rows - 1 and col_idx == cols - 1):
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_qa_grid(self, elem):
|
||||
"""qa-grid 변환 (Q&A 2단 박스)"""
|
||||
items = elem.find_all(class_="qa-item")
|
||||
if not items:
|
||||
return
|
||||
|
||||
print(f" → qa-grid: {len(items)} items")
|
||||
|
||||
self.hwp.create_table(1, 2, treat_as_char=True)
|
||||
|
||||
for i, item in enumerate(items[:2]):
|
||||
if i > 0:
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self._set_cell_bg('bg-light')
|
||||
|
||||
text = item.get_text(strip=True)
|
||||
strong = item.find("strong")
|
||||
if strong:
|
||||
q_text = strong.get_text(strip=True)
|
||||
a_text = text.replace(q_text, '').strip()
|
||||
|
||||
self._font(9.5, 'primary-navy', True)
|
||||
self.hwp.insert_text(q_text)
|
||||
self.hwp.BreakPara()
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(a_text)
|
||||
else:
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(text)
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_bottom_box(self, elem):
|
||||
"""bottom-box 변환 (핵심 결론 박스)"""
|
||||
left = elem.find(class_="bottom-left")
|
||||
right = elem.find(class_="bottom-right")
|
||||
|
||||
if not left or not right:
|
||||
return
|
||||
|
||||
left_text = ' '.join(left.get_text().split())
|
||||
right_text = right.get_text(strip=True)
|
||||
print(f" → bottom-box")
|
||||
|
||||
self.hwp.create_table(1, 2, treat_as_char=True)
|
||||
|
||||
# 좌측 (Navy 배경)
|
||||
self._set_cell_bg('primary-navy')
|
||||
self._font(10.5, 'white', True)
|
||||
self._align('center')
|
||||
self.hwp.insert_text(left_text)
|
||||
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# 우측 (연한 배경)
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(10.5, 'primary-navy', True)
|
||||
self._align('center')
|
||||
self.hwp.insert_text(right_text)
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_section(self, section):
|
||||
"""section 변환"""
|
||||
title = section.find(class_="section-title")
|
||||
if title:
|
||||
self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
|
||||
|
||||
strategy_grid = section.find(class_="strategy-grid")
|
||||
if strategy_grid:
|
||||
self._convert_strategy_grid(strategy_grid)
|
||||
|
||||
process = section.find(class_="process-container")
|
||||
if process:
|
||||
self._convert_process_container(process)
|
||||
|
||||
table = section.find("table", class_="data-table")
|
||||
if table:
|
||||
self._convert_data_table(table)
|
||||
|
||||
ul = section.find("ul")
|
||||
if ul:
|
||||
for li in ul.find_all("li", recursive=False):
|
||||
keyword = li.find(class_="keyword")
|
||||
if keyword:
|
||||
kw_text = keyword.get_text(strip=True)
|
||||
full = li.get_text(strip=True)
|
||||
rest = full.replace(kw_text, '', 1).strip()
|
||||
|
||||
self._font(10.5, 'primary-navy', True)
|
||||
self.hwp.insert_text(" • " + kw_text + " ")
|
||||
self._font(10.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(rest)
|
||||
self.hwp.BreakPara()
|
||||
else:
|
||||
self._para(" • " + li.get_text(strip=True), 10.5, 'dark-gray')
|
||||
|
||||
qa_grid = section.find(class_="qa-grid")
|
||||
if qa_grid:
|
||||
self._convert_qa_grid(qa_grid)
|
||||
|
||||
self._para()
|
||||
|
||||
def _convert_sheet(self, sheet, is_first_page=False, footer_title=""):
|
||||
"""한 페이지(sheet) 변환"""
|
||||
|
||||
# 첫 페이지에서만 머리말/꼬리말 설정
|
||||
if is_first_page:
|
||||
# 머리말: page-header에서 텍스트 추출
|
||||
header = sheet.find(class_="page-header")
|
||||
if header:
|
||||
left = header.find(class_="header-left")
|
||||
right = header.find(class_="header-right")
|
||||
# 우측 텍스트 사용 (부서명 등)
|
||||
header_text = right.get_text(strip=True) if right else ""
|
||||
if header_text:
|
||||
self._create_header(header_text)
|
||||
|
||||
# 꼬리말: 제목 + 페이지번호
|
||||
self._create_footer(footer_title)
|
||||
|
||||
# 대제목
|
||||
title = sheet.find(class_="header-title")
|
||||
if title:
|
||||
title_text = title.get_text(strip=True)
|
||||
if '[첨부]' in title_text:
|
||||
self._para(title_text, 15, 'primary-navy', True, 'left')
|
||||
self._font(10, 'secondary-navy', False)
|
||||
self._align('left')
|
||||
self.hwp.insert_text("─" * 60)
|
||||
self.hwp.BreakPara()
|
||||
else:
|
||||
self._para(title_text, 23, 'primary-navy', True, 'center')
|
||||
self._font(10, 'secondary-navy', False)
|
||||
self._align('center')
|
||||
self.hwp.insert_text("━" * 45)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
self._para()
|
||||
|
||||
# 리드 박스
|
||||
lead_box = sheet.find(class_="lead-box")
|
||||
if lead_box:
|
||||
self._convert_lead_box(lead_box)
|
||||
self._para()
|
||||
|
||||
# 섹션들
|
||||
for section in sheet.find_all(class_="section"):
|
||||
self._convert_section(section)
|
||||
|
||||
# 하단 박스
|
||||
bottom_box = sheet.find(class_="bottom-box")
|
||||
if bottom_box:
|
||||
self._para()
|
||||
self._convert_bottom_box(bottom_box)
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 메인 변환 함수
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def convert(self, html_path, output_path):
|
||||
"""HTML → HWP 변환 실행"""
|
||||
|
||||
print("=" * 60)
|
||||
print("HTML → HWP 변환기 (기획서 전용)")
|
||||
print(" ✓ 머리말/꼬리말: 보고서 방식")
|
||||
print(" ✓ Navy 테마, 기획서 요소")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n[입력] {html_path}")
|
||||
|
||||
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._init_colors()
|
||||
self._setup_page()
|
||||
|
||||
# 페이지별 변환
|
||||
sheets = soup.find_all(class_="sheet")
|
||||
total = len(sheets)
|
||||
print(f"[변환] 총 {total} 페이지\n")
|
||||
|
||||
for i, sheet in enumerate(sheets, 1):
|
||||
print(f"[{i}/{total}] 페이지 처리 중...")
|
||||
self._convert_sheet(sheet, is_first_page=(i == 1), footer_title=footer_title)
|
||||
|
||||
if i < total:
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
|
||||
# 저장
|
||||
self.hwp.SaveAs(output_path)
|
||||
print(f"\n✅ 저장 완료: {output_path}")
|
||||
|
||||
def close(self):
|
||||
"""HWP 종료"""
|
||||
try:
|
||||
self.hwp.Quit()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 실행"""
|
||||
|
||||
html_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.html"
|
||||
output_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.hwp"
|
||||
|
||||
print("=" * 60)
|
||||
print("HTML → HWP 변환기 (기획서)")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
try:
|
||||
converter = HtmlToHwpConverter(visible=True)
|
||||
converter.convert(html_path, output_path)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 변환 완료!")
|
||||
print("=" * 60)
|
||||
|
||||
input("\nEnter를 누르면 HWP가 닫힙니다...")
|
||||
converter.close()
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"\n[에러] 파일을 찾을 수 없습니다: {html_path}")
|
||||
print("경로를 확인해주세요.")
|
||||
except Exception as e:
|
||||
print(f"\n[에러] {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
434
03. Code/geulbeot_10th/converters/hwp_style_mapping.py
Normal file
434
03. Code/geulbeot_10th/converters/hwp_style_mapping.py
Normal file
@@ -0,0 +1,434 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HWP 스타일 매핑 모듈 v2.0
|
||||
HTML 역할(Role) → HWP 스타일 매핑
|
||||
|
||||
✅ v2.0 변경사항:
|
||||
- pyhwpx API에 맞게 apply_to_hwp() 재작성
|
||||
- CharShape/ParaShape 직접 설정 방식
|
||||
- 역할 → 개요 스타일 매핑
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class HwpStyleType(Enum):
|
||||
"""HWP 스타일 유형"""
|
||||
PARAGRAPH = "paragraph"
|
||||
CHARACTER = "character"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HwpStyle:
|
||||
"""HWP 스타일 정의"""
|
||||
id: int
|
||||
name: str
|
||||
type: HwpStyleType
|
||||
font_size: float
|
||||
font_bold: bool = False
|
||||
font_color: str = "000000"
|
||||
align: str = "justify"
|
||||
line_spacing: float = 160
|
||||
space_before: float = 0
|
||||
space_after: float = 0
|
||||
indent_left: float = 0
|
||||
indent_first: float = 0
|
||||
bg_color: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 기본 스타일 템플릿
|
||||
# =============================================================================
|
||||
DEFAULT_STYLES: Dict[str, HwpStyle] = {
|
||||
# 표지
|
||||
"COVER_TITLE": HwpStyle(
|
||||
id=100, name="표지제목", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=32, font_bold=True, align="center",
|
||||
space_before=20, space_after=10, font_color="1a365d"
|
||||
),
|
||||
"COVER_SUBTITLE": HwpStyle(
|
||||
id=101, name="표지부제", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=18, font_bold=False, align="center",
|
||||
font_color="555555"
|
||||
),
|
||||
"COVER_INFO": HwpStyle(
|
||||
id=102, name="표지정보", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=12, align="center", font_color="666666"
|
||||
),
|
||||
|
||||
# 목차
|
||||
"TOC_H1": HwpStyle(
|
||||
id=110, name="목차1수준", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=12, font_bold=True, indent_left=0
|
||||
),
|
||||
"TOC_H2": HwpStyle(
|
||||
id=111, name="목차2수준", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, indent_left=20
|
||||
),
|
||||
"TOC_H3": HwpStyle(
|
||||
id=112, name="목차3수준", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, indent_left=40, font_color="666666"
|
||||
),
|
||||
|
||||
# 제목 계층 (개요 1~7 매핑)
|
||||
"H1": HwpStyle(
|
||||
id=1, name="개요 1", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=20, font_bold=True, align="left",
|
||||
space_before=30, space_after=15, font_color="1a365d"
|
||||
),
|
||||
"H2": HwpStyle(
|
||||
id=2, name="개요 2", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=16, font_bold=True, align="left",
|
||||
space_before=20, space_after=10, font_color="2c5282"
|
||||
),
|
||||
"H3": HwpStyle(
|
||||
id=3, name="개요 3", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=14, font_bold=True, align="left",
|
||||
space_before=15, space_after=8, font_color="2b6cb0"
|
||||
),
|
||||
"H4": HwpStyle(
|
||||
id=4, name="개요 4", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=12, font_bold=True, align="left",
|
||||
space_before=10, space_after=5, indent_left=10
|
||||
),
|
||||
"H5": HwpStyle(
|
||||
id=5, name="개요 5", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, font_bold=True, align="left",
|
||||
space_before=8, space_after=4, indent_left=20
|
||||
),
|
||||
"H6": HwpStyle(
|
||||
id=6, name="개요 6", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, font_bold=False, align="left",
|
||||
indent_left=30
|
||||
),
|
||||
"H7": HwpStyle(
|
||||
id=7, name="개요 7", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10.5, font_bold=False, align="left",
|
||||
indent_left=40
|
||||
),
|
||||
|
||||
# 본문
|
||||
"BODY": HwpStyle(
|
||||
id=20, name="바탕글", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, align="justify",
|
||||
line_spacing=180, indent_first=10
|
||||
),
|
||||
"LIST_ITEM": HwpStyle(
|
||||
id=8, name="개요 8", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, align="left",
|
||||
indent_left=15, line_spacing=160
|
||||
),
|
||||
"HIGHLIGHT_BOX": HwpStyle(
|
||||
id=21, name="강조박스", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10.5, align="left",
|
||||
bg_color="f7fafc", indent_left=10, indent_first=0
|
||||
),
|
||||
|
||||
# 표
|
||||
"TABLE": HwpStyle(
|
||||
id=30, name="표", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="center"
|
||||
),
|
||||
"TH": HwpStyle(
|
||||
id=11, name="표제목", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, font_bold=True, align="center",
|
||||
bg_color="e2e8f0"
|
||||
),
|
||||
"TD": HwpStyle(
|
||||
id=31, name="표내용", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="left"
|
||||
),
|
||||
"TABLE_CAPTION": HwpStyle(
|
||||
id=19, name="표캡션", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, font_bold=True, align="center",
|
||||
space_before=5, space_after=3
|
||||
),
|
||||
|
||||
# 그림
|
||||
"FIGURE": HwpStyle(
|
||||
id=32, name="그림", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="center"
|
||||
),
|
||||
"FIGURE_CAPTION": HwpStyle(
|
||||
id=18, name="그림캡션", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=9.5, align="center",
|
||||
font_color="666666", space_before=5
|
||||
),
|
||||
|
||||
# 기타
|
||||
"UNKNOWN": HwpStyle(
|
||||
id=0, name="바탕글", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="left"
|
||||
),
|
||||
}
|
||||
|
||||
# 역할 → 개요 번호 매핑 (StyleShortcut 용)
|
||||
ROLE_TO_OUTLINE_NUM = {
|
||||
"H1": 1,
|
||||
"H2": 2,
|
||||
"H3": 3,
|
||||
"H4": 4,
|
||||
"H5": 5,
|
||||
"H6": 6,
|
||||
"H7": 7,
|
||||
"LIST_ITEM": 8,
|
||||
"BODY": 0, # 바탕글
|
||||
"COVER_TITLE": 0,
|
||||
"COVER_SUBTITLE": 0,
|
||||
"COVER_INFO": 0,
|
||||
}
|
||||
|
||||
# 역할 → HWP 스타일 이름 매핑
|
||||
ROLE_TO_STYLE_NAME = {
|
||||
"H1": "개요 1",
|
||||
"H2": "개요 2",
|
||||
"H3": "개요 3",
|
||||
"H4": "개요 4",
|
||||
"H5": "개요 5",
|
||||
"H6": "개요 6",
|
||||
"H7": "개요 7",
|
||||
"LIST_ITEM": "개요 8",
|
||||
"BODY": "바탕글",
|
||||
"COVER_TITLE": "표지제목",
|
||||
"COVER_SUBTITLE": "표지부제",
|
||||
"TH": "표제목",
|
||||
"TD": "표내용",
|
||||
"TABLE_CAPTION": "표캡션",
|
||||
"FIGURE_CAPTION": "그림캡션",
|
||||
"UNKNOWN": "바탕글",
|
||||
}
|
||||
|
||||
|
||||
class HwpStyleMapper:
|
||||
"""HTML 역할 → HWP 스타일 매퍼"""
|
||||
|
||||
def __init__(self, custom_styles: Optional[Dict[str, HwpStyle]] = None):
|
||||
self.styles = DEFAULT_STYLES.copy()
|
||||
if custom_styles:
|
||||
self.styles.update(custom_styles)
|
||||
|
||||
def get_style(self, role: str) -> HwpStyle:
|
||||
return self.styles.get(role, self.styles["UNKNOWN"])
|
||||
|
||||
def get_style_id(self, role: str) -> int:
|
||||
return self.get_style(role).id
|
||||
|
||||
def get_all_styles(self) -> Dict[str, HwpStyle]:
|
||||
return self.styles
|
||||
|
||||
|
||||
class HwpStyGenerator:
|
||||
"""
|
||||
HTML 스타일 → HWP 스타일 적용기
|
||||
|
||||
pyhwpx API를 사용하여:
|
||||
1. 역할별 스타일 정보 저장
|
||||
2. 텍스트 삽입 시 CharShape/ParaShape 직접 적용
|
||||
3. 개요 스타일 번호 매핑 반환
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.styles: Dict[str, HwpStyle] = {}
|
||||
self.hwp = None
|
||||
|
||||
def update_from_html(self, html_styles: Dict[str, Dict]):
|
||||
"""HTML에서 추출한 스타일로 업데이트"""
|
||||
for role, style_dict in html_styles.items():
|
||||
if role in DEFAULT_STYLES:
|
||||
base = DEFAULT_STYLES[role]
|
||||
|
||||
# color 처리 - # 제거
|
||||
color = style_dict.get('color', base.font_color)
|
||||
if isinstance(color, str):
|
||||
color = color.lstrip('#')
|
||||
|
||||
self.styles[role] = HwpStyle(
|
||||
id=base.id,
|
||||
name=base.name,
|
||||
type=base.type,
|
||||
font_size=style_dict.get('font_size', base.font_size),
|
||||
font_bold=style_dict.get('bold', base.font_bold),
|
||||
font_color=color,
|
||||
align=style_dict.get('align', base.align),
|
||||
line_spacing=style_dict.get('line_spacing', base.line_spacing),
|
||||
space_before=style_dict.get('space_before', base.space_before),
|
||||
space_after=style_dict.get('space_after', base.space_after),
|
||||
indent_left=style_dict.get('indent_left', base.indent_left),
|
||||
indent_first=style_dict.get('indent_first', base.indent_first),
|
||||
bg_color=style_dict.get('bg_color', base.bg_color),
|
||||
)
|
||||
else:
|
||||
# 기본 스타일 사용
|
||||
self.styles[role] = DEFAULT_STYLES.get('UNKNOWN')
|
||||
|
||||
# 누락된 역할은 기본값으로 채움
|
||||
for role in DEFAULT_STYLES:
|
||||
if role not in self.styles:
|
||||
self.styles[role] = DEFAULT_STYLES[role]
|
||||
|
||||
def apply_to_hwp(self, hwp) -> Dict[str, HwpStyle]:
|
||||
"""역할 → HwpStyle 매핑 반환"""
|
||||
self.hwp = hwp
|
||||
|
||||
# 🚫 스타일 생성 비활성화 (API 문제)
|
||||
# for role, style in self.styles.items():
|
||||
# self._create_or_update_style(hwp, role, style)
|
||||
|
||||
if not self.styles:
|
||||
self.styles = DEFAULT_STYLES.copy()
|
||||
|
||||
print(f" ✅ 스타일 매핑 완료: {len(self.styles)}개")
|
||||
return self.styles
|
||||
|
||||
def _create_or_update_style(self, hwp, role: str, style: HwpStyle):
|
||||
"""HWP에 스타일 생성 또는 수정"""
|
||||
try:
|
||||
# 1. 스타일 편집 모드
|
||||
hwp.HAction.GetDefault("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
|
||||
hwp.HParameterSet.HStyle.StyleName = style.name
|
||||
|
||||
# 2. 글자 모양
|
||||
color_hex = style.font_color.lstrip('#')
|
||||
if len(color_hex) == 6:
|
||||
r, g, b = int(color_hex[0:2], 16), int(color_hex[2:4], 16), int(color_hex[4:6], 16)
|
||||
text_color = hwp.RGBColor(r, g, b)
|
||||
else:
|
||||
text_color = hwp.RGBColor(0, 0, 0)
|
||||
|
||||
hwp.HParameterSet.HStyle.CharShape.Height = hwp.PointToHwpUnit(style.font_size)
|
||||
hwp.HParameterSet.HStyle.CharShape.Bold = style.font_bold
|
||||
hwp.HParameterSet.HStyle.CharShape.TextColor = text_color
|
||||
|
||||
# 3. 문단 모양
|
||||
align_map = {'left': 0, 'center': 1, 'right': 2, 'justify': 3}
|
||||
hwp.HParameterSet.HStyle.ParaShape.Align = align_map.get(style.align, 3)
|
||||
hwp.HParameterSet.HStyle.ParaShape.LineSpacing = int(style.line_spacing)
|
||||
hwp.HParameterSet.HStyle.ParaShape.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
|
||||
hwp.HParameterSet.HStyle.ParaShape.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
|
||||
|
||||
# 4. 실행
|
||||
hwp.HAction.Execute("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
|
||||
print(f" ✓ 스타일 '{style.name}' 정의됨")
|
||||
|
||||
except Exception as e:
|
||||
print(f" [경고] 스타일 '{style.name}' 생성 실패: {e}")
|
||||
|
||||
def get_style(self, role: str) -> HwpStyle:
|
||||
"""역할에 해당하는 스타일 반환"""
|
||||
return self.styles.get(role, DEFAULT_STYLES.get('UNKNOWN'))
|
||||
|
||||
def apply_char_shape(self, hwp, role: str):
|
||||
"""현재 선택 영역에 글자 모양 적용"""
|
||||
style = self.get_style(role)
|
||||
|
||||
try:
|
||||
# RGB 색상 변환
|
||||
color_hex = style.font_color.lstrip('#') if style.font_color else '000000'
|
||||
if len(color_hex) == 6:
|
||||
r = int(color_hex[0:2], 16)
|
||||
g = int(color_hex[2:4], 16)
|
||||
b = int(color_hex[4:6], 16)
|
||||
text_color = hwp.RGBColor(r, g, b)
|
||||
else:
|
||||
text_color = hwp.RGBColor(0, 0, 0)
|
||||
|
||||
# 글자 모양 설정
|
||||
hwp.HAction.GetDefault("CharShape", hwp.HParameterSet.HCharShape.HSet)
|
||||
hwp.HParameterSet.HCharShape.Height = hwp.PointToHwpUnit(style.font_size)
|
||||
hwp.HParameterSet.HCharShape.Bold = style.font_bold
|
||||
hwp.HParameterSet.HCharShape.TextColor = text_color
|
||||
hwp.HAction.Execute("CharShape", hwp.HParameterSet.HCharShape.HSet)
|
||||
|
||||
except Exception as e:
|
||||
print(f" [경고] 글자 모양 적용 실패 ({role}): {e}")
|
||||
|
||||
def apply_para_shape(self, hwp, role: str):
|
||||
"""현재 문단에 문단 모양 적용"""
|
||||
style = self.get_style(role)
|
||||
|
||||
try:
|
||||
# 정렬
|
||||
align_actions = {
|
||||
'left': "ParagraphShapeAlignLeft",
|
||||
'center': "ParagraphShapeAlignCenter",
|
||||
'right': "ParagraphShapeAlignRight",
|
||||
'justify': "ParagraphShapeAlignJustify"
|
||||
}
|
||||
if style.align in align_actions:
|
||||
hwp.HAction.Run(align_actions[style.align])
|
||||
|
||||
# 문단 모양 상세 설정
|
||||
hwp.HAction.GetDefault("ParagraphShape", hwp.HParameterSet.HParaShape.HSet)
|
||||
p = hwp.HParameterSet.HParaShape
|
||||
p.LineSpaceType = 0 # 퍼센트
|
||||
p.LineSpacing = int(style.line_spacing)
|
||||
p.LeftMargin = hwp.MiliToHwpUnit(style.indent_left)
|
||||
p.IndentMargin = hwp.MiliToHwpUnit(style.indent_first)
|
||||
p.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
|
||||
p.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
|
||||
hwp.HAction.Execute("ParagraphShape", p.HSet)
|
||||
|
||||
except Exception as e:
|
||||
print(f" [경고] 문단 모양 적용 실패 ({role}): {e}")
|
||||
|
||||
def apply_style(self, hwp, role: str):
|
||||
"""역할에 맞는 전체 스타일 적용 (글자 + 문단)"""
|
||||
self.apply_char_shape(hwp, role)
|
||||
self.apply_para_shape(hwp, role)
|
||||
|
||||
def export_sty(self, hwp, output_path: str) -> bool:
|
||||
"""스타일 파일 내보내기 (현재 미지원)"""
|
||||
print(f" [알림] .sty 내보내기는 현재 미지원")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 번호 제거 유틸리티
|
||||
# =============================================================================
|
||||
import re
|
||||
|
||||
NUMBERING_PATTERNS = {
|
||||
'H1': re.compile(r'^(\d+)\.\s*'), # "1. " → ""
|
||||
'H2': re.compile(r'^(\d+)\.(\d+)\s*'), # "1.1 " → ""
|
||||
'H3': re.compile(r'^(\d+)\.(\d+)\.(\d+)\s*'), # "1.1.1 " → ""
|
||||
'H4': re.compile(r'^[가-하]\.\s*'), # "가. " → ""
|
||||
'H5': re.compile(r'^(\d+)\)\s*'), # "1) " → ""
|
||||
'H6': re.compile(r'^\((\d+)\)\s*'), # "(1) " → ""
|
||||
'H7': re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]\s*'), # "① " → ""
|
||||
'LIST_ITEM': re.compile(r'^[•\-○]\s*'), # "• " → ""
|
||||
}
|
||||
|
||||
def strip_numbering(text: str, role: str) -> str:
|
||||
"""
|
||||
역할에 따라 텍스트 앞의 번호/기호 제거
|
||||
HWP 개요 기능이 번호를 자동 생성하므로 중복 방지
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
pattern = NUMBERING_PATTERNS.get(role)
|
||||
if pattern:
|
||||
return pattern.sub('', text).strip()
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
print("=== 스타일 매핑 테스트 ===")
|
||||
|
||||
gen = HwpStyGenerator()
|
||||
|
||||
# HTML 스타일 시뮬레이션
|
||||
html_styles = {
|
||||
'H1': {'font_size': 20, 'color': '#1a365d', 'bold': True},
|
||||
'H2': {'font_size': 16, 'color': '#2c5282', 'bold': True},
|
||||
'BODY': {'font_size': 11, 'align': 'justify'},
|
||||
}
|
||||
|
||||
gen.update_from_html(html_styles)
|
||||
|
||||
for role, style in gen.styles.items():
|
||||
print(f"{role:15} → size={style.font_size}pt, bold={style.font_bold}, color=#{style.font_color}")
|
||||
431
03. Code/geulbeot_10th/converters/hwpx_generator.py
Normal file
431
03. Code/geulbeot_10th/converters/hwpx_generator.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
HWPX 파일 생성기
|
||||
StyleAnalyzer 결과를 받아 스타일이 적용된 HWPX 파일 생성
|
||||
"""
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from style_analyzer import StyleAnalyzer, StyledElement
|
||||
from hwp_style_mapping import HwpStyleMapper, HwpStyle, ROLE_TO_STYLE_NAME
|
||||
|
||||
|
||||
@dataclass
|
||||
class HwpxConfig:
|
||||
"""HWPX 생성 설정"""
|
||||
paper_width: int = 59528 # A4 너비 (hwpunit, 1/7200 inch)
|
||||
paper_height: int = 84188 # A4 높이
|
||||
margin_left: int = 8504
|
||||
margin_right: int = 8504
|
||||
margin_top: int = 5668
|
||||
margin_bottom: int = 4252
|
||||
default_font: str = "함초롬바탕"
|
||||
default_font_size: int = 1000 # 10pt (hwpunit)
|
||||
|
||||
|
||||
class HwpxGenerator:
|
||||
"""HWPX 파일 생성기"""
|
||||
|
||||
def __init__(self, config: Optional[HwpxConfig] = None):
|
||||
self.config = config or HwpxConfig()
|
||||
self.mapper = HwpStyleMapper()
|
||||
self.used_styles: set = set()
|
||||
|
||||
def generate(self, elements: List[StyledElement], output_path: str) -> str:
|
||||
"""
|
||||
StyledElement 리스트로부터 HWPX 파일 생성
|
||||
|
||||
Args:
|
||||
elements: StyleAnalyzer로 분류된 요소 리스트
|
||||
output_path: 출력 파일 경로 (.hwpx)
|
||||
|
||||
Returns:
|
||||
생성된 파일 경로
|
||||
"""
|
||||
# 사용된 스타일 수집
|
||||
self.used_styles = {e.role for e in elements}
|
||||
|
||||
# 임시 디렉토리 생성
|
||||
temp_dir = Path(output_path).with_suffix('.temp')
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# HWPX 구조 생성
|
||||
self._create_mimetype(temp_dir)
|
||||
self._create_meta_inf(temp_dir)
|
||||
self._create_version(temp_dir)
|
||||
self._create_header(temp_dir)
|
||||
self._create_content(temp_dir, elements)
|
||||
self._create_settings(temp_dir)
|
||||
|
||||
# ZIP으로 압축
|
||||
self._create_hwpx(temp_dir, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
finally:
|
||||
# 임시 파일 정리
|
||||
import shutil
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def _create_mimetype(self, temp_dir: Path):
|
||||
"""mimetype 파일 생성"""
|
||||
mimetype_path = temp_dir / "mimetype"
|
||||
mimetype_path.write_text("application/hwp+zip")
|
||||
|
||||
def _create_meta_inf(self, temp_dir: Path):
|
||||
"""META-INF/manifest.xml 생성"""
|
||||
meta_dir = temp_dir / "META-INF"
|
||||
meta_dir.mkdir(exist_ok=True)
|
||||
|
||||
manifest = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwp+zip"/>
|
||||
<manifest:file-entry manifest:full-path="version.xml" manifest:media-type="application/xml"/>
|
||||
<manifest:file-entry manifest:full-path="Contents/header.xml" manifest:media-type="application/xml"/>
|
||||
<manifest:file-entry manifest:full-path="Contents/section0.xml" manifest:media-type="application/xml"/>
|
||||
<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="application/xml"/>
|
||||
</manifest:manifest>"""
|
||||
|
||||
(meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8')
|
||||
|
||||
def _create_version(self, temp_dir: Path):
|
||||
"""version.xml 생성"""
|
||||
version = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hh:HWPMLVersion xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" version="1.1"/>"""
|
||||
|
||||
(temp_dir / "version.xml").write_text(version, encoding='utf-8')
|
||||
|
||||
def _create_header(self, temp_dir: Path):
|
||||
"""Contents/header.xml 생성 (스타일 정의 포함)"""
|
||||
contents_dir = temp_dir / "Contents"
|
||||
contents_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 스타일별 속성 생성
|
||||
char_props_xml = self._generate_char_properties()
|
||||
para_props_xml = self._generate_para_properties()
|
||||
styles_xml = self._generate_styles_xml()
|
||||
|
||||
header = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head"
|
||||
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core"
|
||||
xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
|
||||
version="1.5" secCnt="1">
|
||||
<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
|
||||
<hh:refList>
|
||||
<hh:fontfaces itemCnt="7">
|
||||
<hh:fontface lang="HANGUL" fontCnt="2">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="LATIN" fontCnt="2">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="HANJA" fontCnt="2">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="JAPANESE" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="OTHER" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="SYMBOL" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="USER" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
</hh:fontfaces>
|
||||
<hh:borderFills itemCnt="2">
|
||||
<hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE">
|
||||
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
</hh:borderFill>
|
||||
<hh:borderFill id="2" threeD="0" shadow="0" centerLine="NONE">
|
||||
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hc:fillBrush><hc:winBrush faceColor="none" hatchColor="#000000" alpha="0"/></hc:fillBrush>
|
||||
</hh:borderFill>
|
||||
</hh:borderFills>
|
||||
{char_props_xml}
|
||||
{para_props_xml}
|
||||
{styles_xml}
|
||||
</hh:refList>
|
||||
<hh:compatibleDocument targetProgram="HWP201X"/>
|
||||
<hh:docOption>
|
||||
<hh:linkinfo path="" pageInherit="1" footnoteInherit="0"/>
|
||||
</hh:docOption>
|
||||
</hh:head>"""
|
||||
|
||||
(contents_dir / "header.xml").write_text(header, encoding='utf-8')
|
||||
|
||||
def _generate_char_properties(self) -> str:
|
||||
"""글자 속성 XML 생성"""
|
||||
lines = [f' <hh:charProperties itemCnt="{len(self.used_styles) + 1}">']
|
||||
|
||||
# 기본 글자 속성 (id=0)
|
||||
lines.append(''' <hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
|
||||
<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
|
||||
<hh:strikeout shape="NONE" color="#000000"/>
|
||||
<hh:outline type="NONE"/>
|
||||
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
|
||||
</hh:charPr>''')
|
||||
|
||||
# 역할별 글자 속성
|
||||
for idx, role in enumerate(sorted(self.used_styles), start=1):
|
||||
style = self.mapper.get_style(role)
|
||||
height = int(style.font_size * 100) # pt → hwpunit
|
||||
color = style.font_color.lstrip('#')
|
||||
font_id = "1" if style.font_bold else "0" # 굵게면 함초롬돋움
|
||||
|
||||
lines.append(f''' <hh:charPr id="{idx}" height="{height}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
|
||||
<hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/>
|
||||
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
|
||||
<hh:strikeout shape="NONE" color="#000000"/>
|
||||
<hh:outline type="NONE"/>
|
||||
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
|
||||
</hh:charPr>''')
|
||||
|
||||
lines.append(' </hh:charProperties>')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _generate_para_properties(self) -> str:
|
||||
"""문단 속성 XML 생성"""
|
||||
lines = [f' <hh:paraProperties itemCnt="{len(self.used_styles) + 1}">']
|
||||
|
||||
# 기본 문단 속성 (id=0)
|
||||
lines.append(''' <hh:paraPr id="0" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">
|
||||
<hh:align horizontal="JUSTIFY" vertical="BASELINE"/>
|
||||
<hh:heading type="NONE" idRef="0" level="0"/>
|
||||
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>
|
||||
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
|
||||
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
|
||||
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
|
||||
<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>
|
||||
</hp:case>
|
||||
<hp:default>
|
||||
<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>
|
||||
</hp:default>
|
||||
</hp:switch>
|
||||
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
|
||||
</hh:paraPr>''')
|
||||
|
||||
# 역할별 문단 속성
|
||||
align_map = {"left": "LEFT", "center": "CENTER", "right": "RIGHT", "justify": "JUSTIFY"}
|
||||
|
||||
for idx, role in enumerate(sorted(self.used_styles), start=1):
|
||||
style = self.mapper.get_style(role)
|
||||
align_val = align_map.get(style.align, "JUSTIFY")
|
||||
line_spacing = int(style.line_spacing)
|
||||
left_margin = int(style.indent_left * 100)
|
||||
indent = int(style.indent_first * 100)
|
||||
space_before = int(style.space_before * 100)
|
||||
space_after = int(style.space_after * 100)
|
||||
|
||||
lines.append(f''' <hh:paraPr id="{idx}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">
|
||||
<hh:align horizontal="{align_val}" vertical="BASELINE"/>
|
||||
<hh:heading type="NONE" idRef="0" level="0"/>
|
||||
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>
|
||||
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
|
||||
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
|
||||
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
|
||||
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
|
||||
</hp:case>
|
||||
<hp:default>
|
||||
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
|
||||
</hp:default>
|
||||
</hp:switch>
|
||||
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
|
||||
</hh:paraPr>''')
|
||||
|
||||
lines.append(' </hh:paraProperties>')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _generate_styles_xml(self) -> str:
|
||||
"""스타일 정의 XML 생성 (charPrIDRef, paraPrIDRef 참조)"""
|
||||
lines = [f' <hh:styles itemCnt="{len(self.used_styles) + 1}">']
|
||||
|
||||
# 기본 스타일 (id=0, 바탕글)
|
||||
lines.append(' <hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/>')
|
||||
|
||||
# 역할별 스타일 (charPrIDRef, paraPrIDRef 참조)
|
||||
for idx, role in enumerate(sorted(self.used_styles), start=1):
|
||||
style = self.mapper.get_style(role)
|
||||
style_name = style.name.replace('<', '<').replace('>', '>')
|
||||
|
||||
lines.append(f' <hh:style id="{idx}" type="PARA" name="{style_name}" engName="" paraPrIDRef="{idx}" charPrIDRef="{idx}" nextStyleIDRef="{idx}" langID="1042" lockForm="0"/>')
|
||||
|
||||
lines.append(' </hh:styles>')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _create_content(self, temp_dir: Path, elements: List[StyledElement]):
|
||||
"""Contents/section0.xml 생성 (본문 + 스타일 참조)"""
|
||||
contents_dir = temp_dir / "Contents"
|
||||
|
||||
# 문단 XML 생성
|
||||
paragraphs = []
|
||||
current_table = None
|
||||
|
||||
# 역할 → 스타일 인덱스 매핑 생성
|
||||
role_to_idx = {role: idx for idx, role in enumerate(sorted(self.used_styles), start=1)}
|
||||
|
||||
for elem in elements:
|
||||
style = self.mapper.get_style(elem.role)
|
||||
style_idx = role_to_idx.get(elem.role, 0)
|
||||
|
||||
# 테이블 요소는 특수 처리
|
||||
if elem.role in ["TH", "TD", "TABLE_CAPTION", "TABLE", "FIGURE"]:
|
||||
continue # 테이블/그림은 별도 처리 필요
|
||||
|
||||
# 일반 문단
|
||||
para_xml = self._create_paragraph(elem.text, style, style_idx)
|
||||
paragraphs.append(para_xml)
|
||||
|
||||
section = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section"
|
||||
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core">
|
||||
{"".join(paragraphs)}
|
||||
</hs:sec>"""
|
||||
|
||||
(contents_dir / "section0.xml").write_text(section, encoding='utf-8')
|
||||
|
||||
def _create_paragraph(self, text: str, style: HwpStyle, style_idx: int) -> str:
|
||||
"""단일 문단 XML 생성"""
|
||||
text = self._escape_xml(text)
|
||||
|
||||
return f'''
|
||||
<hp:p xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
|
||||
paraPrIDRef="{style_idx}" styleIDRef="{style_idx}" pageBreak="0" columnBreak="0" merged="0">
|
||||
<hp:run charPrIDRef="{style_idx}">
|
||||
<hp:t>{text}</hp:t>
|
||||
</hp:run>
|
||||
</hp:p>'''
|
||||
|
||||
def _escape_xml(self, text: str) -> str:
|
||||
"""XML 특수문자 이스케이프"""
|
||||
return (text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'"))
|
||||
|
||||
def _create_settings(self, temp_dir: Path):
|
||||
"""settings.xml 생성"""
|
||||
settings = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hs:settings xmlns:hs="http://www.hancom.co.kr/hwpml/2011/settings">
|
||||
<hs:viewSetting>
|
||||
<hs:viewType val="printView"/>
|
||||
<hs:zoom val="100"/>
|
||||
</hs:viewSetting>
|
||||
</hs:settings>"""
|
||||
|
||||
(temp_dir / "settings.xml").write_text(settings, encoding='utf-8')
|
||||
|
||||
def _create_hwpx(self, temp_dir: Path, output_path: str):
|
||||
"""HWPX 파일 생성 (ZIP 압축)"""
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# mimetype은 압축하지 않고 첫 번째로
|
||||
mimetype_path = temp_dir / "mimetype"
|
||||
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# 나머지 파일들
|
||||
for root, dirs, files in os.walk(temp_dir):
|
||||
for file in files:
|
||||
if file == "mimetype":
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
arcname = file_path.relative_to(temp_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
|
||||
def convert_html_to_hwpx(html: str, output_path: str) -> str:
|
||||
"""
|
||||
HTML → HWPX 변환 메인 함수
|
||||
|
||||
Args:
|
||||
html: HTML 문자열
|
||||
output_path: 출력 파일 경로
|
||||
|
||||
Returns:
|
||||
생성된 파일 경로
|
||||
"""
|
||||
# 1. HTML 분석 → 역할 분류
|
||||
analyzer = StyleAnalyzer()
|
||||
elements = analyzer.analyze(html)
|
||||
|
||||
print(f"📊 분석 완료: {len(elements)}개 요소")
|
||||
for role, count in analyzer.get_role_summary().items():
|
||||
print(f" {role}: {count}")
|
||||
|
||||
# 2. HWPX 생성
|
||||
generator = HwpxGenerator()
|
||||
result_path = generator.generate(elements, output_path)
|
||||
|
||||
print(f"✅ 생성 완료: {result_path}")
|
||||
return result_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
test_html = """
|
||||
<html>
|
||||
<body>
|
||||
<div class="box-cover">
|
||||
<h1>건설·토목 측량 DX 실무지침</h1>
|
||||
<h2>드론/UAV·GIS·지형/지반 모델 기반</h2>
|
||||
<p>2024년 1월</p>
|
||||
</div>
|
||||
|
||||
<h1>1. 개요</h1>
|
||||
<p>본 보고서는 건설 및 토목 분야의 측량 디지털 전환에 대한 실무 지침을 제공합니다.</p>
|
||||
|
||||
<h2>1.1 배경</h2>
|
||||
<p>최근 드론과 GIS 기술의 발전으로 측량 업무가 크게 변화하고 있습니다.</p>
|
||||
|
||||
<h3>1.1.1 기술 동향</h3>
|
||||
<p>1) <strong>드론 측량의 발전</strong></p>
|
||||
<p>드론을 활용한 측량은 기존 방식 대비 효율성이 크게 향상되었습니다.</p>
|
||||
|
||||
<p>(1) <strong>RTK 드론</strong></p>
|
||||
<p>실시간 보정 기능을 갖춘 RTK 드론이 보급되고 있습니다.</p>
|
||||
|
||||
<ul>
|
||||
<li>고정밀 GPS 수신기 내장</li>
|
||||
<li>센티미터 단위 정확도</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
output = "/home/claude/test_output.hwpx"
|
||||
convert_html_to_hwpx(test_html, output)
|
||||
750
03. Code/geulbeot_10th/converters/hwpx_style_injector.py
Normal file
750
03. Code/geulbeot_10th/converters/hwpx_style_injector.py
Normal file
@@ -0,0 +1,750 @@
|
||||
"""
|
||||
HWPX 스타일 주입기
|
||||
pyhwpx로 생성된 HWPX 파일에 커스텀 스타일을 후처리로 주입
|
||||
|
||||
워크플로우:
|
||||
1. HWPX 압축 해제
|
||||
2. header.xml에 커스텀 스타일 정의 추가
|
||||
3. section*.xml에서 역할별 styleIDRef 매핑
|
||||
4. 다시 압축
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class StyleDefinition:
|
||||
"""스타일 정의"""
|
||||
id: int
|
||||
name: str
|
||||
font_size: int # hwpunit (pt * 100)
|
||||
font_bold: bool
|
||||
font_color: str # #RRGGBB
|
||||
align: str # LEFT, CENTER, RIGHT, JUSTIFY
|
||||
line_spacing: int # percent (160 = 160%)
|
||||
indent_left: int # hwpunit
|
||||
indent_first: int # hwpunit
|
||||
space_before: int # hwpunit
|
||||
space_after: int # hwpunit
|
||||
outline_level: int = -1 # 🆕 개요 수준 (-1=없음, 0=1수준, 1=2수준, ...)
|
||||
|
||||
|
||||
# 역할 → 스타일 정의 매핑
|
||||
ROLE_STYLES: Dict[str, StyleDefinition] = {
|
||||
# 🆕 개요 문단 (자동 번호 매기기!)
|
||||
'H1': StyleDefinition(
|
||||
id=101, name='제1장 제목', font_size=2200, font_bold=True,
|
||||
font_color='#006400', align='CENTER', line_spacing=200,
|
||||
indent_left=0, indent_first=0, space_before=400, space_after=200,
|
||||
outline_level=0 # 🆕 제^1장
|
||||
),
|
||||
'H2': StyleDefinition(
|
||||
id=102, name='1.1 제목', font_size=1500, font_bold=True,
|
||||
font_color='#03581d', align='LEFT', line_spacing=200,
|
||||
indent_left=0, indent_first=0, space_before=300, space_after=100,
|
||||
outline_level=1 # 🆕 ^1.^2
|
||||
),
|
||||
'H3': StyleDefinition(
|
||||
id=103, name='1.1.1 제목', font_size=1400, font_bold=True,
|
||||
font_color='#228B22', align='LEFT', line_spacing=200,
|
||||
indent_left=500, indent_first=0, space_before=200, space_after=100,
|
||||
outline_level=2 # 🆕 ^1.^2.^3
|
||||
),
|
||||
'H4': StyleDefinition(
|
||||
id=104, name='가. 제목', font_size=1300, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=1000, indent_first=0, space_before=150, space_after=50,
|
||||
outline_level=3 # 🆕 ^4.
|
||||
),
|
||||
'H5': StyleDefinition(
|
||||
id=105, name='1) 제목', font_size=1200, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=1500, indent_first=0, space_before=100, space_after=50,
|
||||
outline_level=4 # 🆕 ^5)
|
||||
),
|
||||
'H6': StyleDefinition(
|
||||
id=106, name='가) 제목', font_size=1150, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=2000, indent_first=0, space_before=100, space_after=50,
|
||||
outline_level=5 # 🆕 ^6)
|
||||
),
|
||||
'H7': StyleDefinition(
|
||||
id=115, name='① 제목', font_size=1100, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=2300, indent_first=0, space_before=100, space_after=50,
|
||||
outline_level=6 # 🆕 ^7 (원문자)
|
||||
),
|
||||
# 본문 스타일 (개요 아님)
|
||||
'BODY': StyleDefinition(
|
||||
id=107, name='○본문', font_size=1100, font_bold=False,
|
||||
font_color='#000000', align='JUSTIFY', line_spacing=200,
|
||||
indent_left=1500, indent_first=0, space_before=0, space_after=0
|
||||
),
|
||||
'LIST_ITEM': StyleDefinition(
|
||||
id=108, name='●본문', font_size=1050, font_bold=False,
|
||||
font_color='#000000', align='JUSTIFY', line_spacing=200,
|
||||
indent_left=2500, indent_first=0, space_before=0, space_after=0
|
||||
),
|
||||
'TABLE_CAPTION': StyleDefinition(
|
||||
id=109, name='<표 제목>', font_size=1100, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=130,
|
||||
indent_left=0, indent_first=0, space_before=200, space_after=100
|
||||
),
|
||||
'FIGURE_CAPTION': StyleDefinition(
|
||||
id=110, name='<그림 제목>', font_size=1100, font_bold=True,
|
||||
font_color='#000000', align='CENTER', line_spacing=130,
|
||||
indent_left=0, indent_first=0, space_before=100, space_after=200
|
||||
),
|
||||
'COVER_TITLE': StyleDefinition(
|
||||
id=111, name='표지제목', font_size=2800, font_bold=True,
|
||||
font_color='#1a365d', align='CENTER', line_spacing=150,
|
||||
indent_left=0, indent_first=0, space_before=0, space_after=200
|
||||
),
|
||||
'COVER_SUBTITLE': StyleDefinition(
|
||||
id=112, name='표지부제', font_size=1800, font_bold=False,
|
||||
font_color='#2d3748', align='CENTER', line_spacing=150,
|
||||
indent_left=0, indent_first=0, space_before=0, space_after=100
|
||||
),
|
||||
'TOC_1': StyleDefinition(
|
||||
id=113, name='목차1수준', font_size=1200, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=180,
|
||||
indent_left=0, indent_first=0, space_before=100, space_after=50
|
||||
),
|
||||
'TOC_2': StyleDefinition(
|
||||
id=114, name='목차2수준', font_size=1100, font_bold=False,
|
||||
font_color='#000000', align='LEFT', line_spacing=180,
|
||||
indent_left=500, indent_first=0, space_before=0, space_after=0
|
||||
),
|
||||
}
|
||||
|
||||
# ⚠️ 개요 자동 번호 기능 활성화!
|
||||
# idRef="0"은 numbering id=1을 참조하므로, 해당 패턴을 교체하면 동작함
|
||||
|
||||
|
||||
class HwpxStyleInjector:
|
||||
"""HWPX 스타일 주입기"""
|
||||
|
||||
def __init__(self):
|
||||
self.temp_dir: Optional[Path] = None
|
||||
self.role_to_style_id: Dict[str, int] = {}
|
||||
self.role_to_para_id: Dict[str, int] = {} # 🆕
|
||||
self.role_to_char_id: Dict[str, int] = {} # 🆕
|
||||
self.next_char_id = 0
|
||||
self.next_para_id = 0
|
||||
self.next_style_id = 0
|
||||
|
||||
def _find_max_ids(self):
|
||||
"""기존 스타일 교체: 바탕글(id=0)만 유지, 나머지는 우리 스타일로 교체"""
|
||||
header_path = self.temp_dir / "Contents" / "header.xml"
|
||||
if not header_path.exists():
|
||||
self.next_char_id = 1
|
||||
self.next_para_id = 1
|
||||
self.next_style_id = 1
|
||||
return
|
||||
|
||||
content = header_path.read_text(encoding='utf-8')
|
||||
|
||||
# 🆕 기존 "본문", "개요 1~10" 등 스타일 제거 (id=1~22)
|
||||
# 바탕글(id=0)만 유지!
|
||||
|
||||
# style id=1~30 제거 (바탕글 제외)
|
||||
content = re.sub(r'<hh:style id="([1-9]|[12]\d|30)"[^/]*/>\s*', '', content)
|
||||
|
||||
# itemCnt는 나중에 _update_item_counts에서 자동 업데이트됨
|
||||
|
||||
# 파일 저장
|
||||
header_path.write_text(content, encoding='utf-8')
|
||||
print(f" [INFO] 기존 스타일(본문, 개요1~10 등) 제거 완료")
|
||||
|
||||
# charPr, paraPr은 기존 것 다음부터 (참조 깨지지 않도록)
|
||||
char_ids = [int(m) for m in re.findall(r'<hh:charPr id="(\d+)"', content)]
|
||||
self.next_char_id = max(char_ids) + 1 if char_ids else 20
|
||||
|
||||
para_ids = [int(m) for m in re.findall(r'<hh:paraPr id="(\d+)"', content)]
|
||||
self.next_para_id = max(para_ids) + 1 if para_ids else 20
|
||||
|
||||
# 스타일은 1부터 시작! (Ctrl+2 = id=1, Ctrl+3 = id=2, ...)
|
||||
self.next_style_id = 1
|
||||
|
||||
def inject(self, hwpx_path: str, role_positions: Dict[str, List[tuple]]) -> str:
|
||||
"""
|
||||
HWPX 파일에 커스텀 스타일 주입
|
||||
|
||||
Args:
|
||||
hwpx_path: 원본 HWPX 파일 경로
|
||||
role_positions: 역할별 위치 정보 {role: [(section_idx, para_idx), ...]}
|
||||
|
||||
Returns:
|
||||
수정된 HWPX 파일 경로
|
||||
"""
|
||||
print(f"\n🎨 HWPX 스타일 주입 시작...")
|
||||
print(f" 입력: {hwpx_path}")
|
||||
|
||||
# 1. 임시 디렉토리에 압축 해제
|
||||
self.temp_dir = Path(tempfile.mkdtemp(prefix='hwpx_inject_'))
|
||||
print(f" 임시 폴더: {self.temp_dir}")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(hwpx_path, 'r') as zf:
|
||||
zf.extractall(self.temp_dir)
|
||||
|
||||
# 압축 해제 직후 section 파일 크기 확인
|
||||
print(f" [DEBUG] After unzip:")
|
||||
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
|
||||
sec_path = self.temp_dir / "Contents" / sec
|
||||
if sec_path.exists():
|
||||
print(f" [DEBUG] {sec} size: {sec_path.stat().st_size} bytes")
|
||||
|
||||
# 🆕 기존 최대 ID 찾기 (연속 ID 할당을 위해)
|
||||
self._find_max_ids()
|
||||
print(f" [DEBUG] Starting IDs: char={self.next_char_id}, para={self.next_para_id}, style={self.next_style_id}")
|
||||
|
||||
# 2. header.xml에 스타일 정의 추가
|
||||
used_roles = set(role_positions.keys())
|
||||
self._inject_header_styles(used_roles)
|
||||
|
||||
# 3. section*.xml에 styleIDRef 매핑
|
||||
self._inject_section_styles(role_positions)
|
||||
|
||||
# 4. 다시 압축
|
||||
output_path = hwpx_path # 원본 덮어쓰기
|
||||
self._repack_hwpx(output_path)
|
||||
|
||||
print(f" ✅ 스타일 주입 완료: {output_path}")
|
||||
return output_path
|
||||
|
||||
finally:
|
||||
# 임시 폴더 정리
|
||||
if self.temp_dir and self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def _inject_header_styles(self, used_roles: set):
|
||||
"""header.xml에 스타일 정의 추가 (모든 ROLE_STYLES 주입)"""
|
||||
header_path = self.temp_dir / "Contents" / "header.xml"
|
||||
if not header_path.exists():
|
||||
print(" [경고] header.xml 없음")
|
||||
return
|
||||
|
||||
content = header_path.read_text(encoding='utf-8')
|
||||
|
||||
# 🆕 모든 ROLE_STYLES 주입 (used_roles 무시)
|
||||
char_props = []
|
||||
para_props = []
|
||||
styles = []
|
||||
|
||||
for role, style_def in ROLE_STYLES.items():
|
||||
char_id = self.next_char_id
|
||||
para_id = self.next_para_id
|
||||
style_id = self.next_style_id
|
||||
|
||||
self.role_to_style_id[role] = style_id
|
||||
self.role_to_para_id[role] = para_id # 🆕
|
||||
self.role_to_char_id[role] = char_id # 🆕
|
||||
|
||||
# charPr 생성
|
||||
char_props.append(self._make_char_pr(char_id, style_def))
|
||||
|
||||
# paraPr 생성
|
||||
para_props.append(self._make_para_pr(para_id, style_def))
|
||||
|
||||
# style 생성
|
||||
styles.append(self._make_style(style_id, style_def.name, para_id, char_id))
|
||||
|
||||
self.next_char_id += 1
|
||||
self.next_para_id += 1
|
||||
self.next_style_id += 1
|
||||
|
||||
if not styles:
|
||||
print(" [정보] 주입할 스타일 없음")
|
||||
return
|
||||
|
||||
# charProperties에 추가
|
||||
content = self._insert_before_tag(
|
||||
content, '</hh:charProperties>', '\n'.join(char_props) + '\n'
|
||||
)
|
||||
|
||||
# paraProperties에 추가
|
||||
content = self._insert_before_tag(
|
||||
content, '</hh:paraProperties>', '\n'.join(para_props) + '\n'
|
||||
)
|
||||
|
||||
# styles에 추가
|
||||
content = self._insert_before_tag(
|
||||
content, '</hh:styles>', '\n'.join(styles) + '\n'
|
||||
)
|
||||
|
||||
# 🆕 numbering id=1 패턴 교체 (idRef="0"이 참조하는 기본 번호 모양)
|
||||
# 이렇게 하면 개요 자동 번호가 "제1장, 1.1, 1.1.1..." 형식으로 동작!
|
||||
content = self._replace_default_numbering(content)
|
||||
|
||||
# itemCnt 업데이트
|
||||
content = self._update_item_counts(content)
|
||||
|
||||
header_path.write_text(content, encoding='utf-8')
|
||||
print(f" → header.xml 수정 완료 ({len(styles)}개 스타일 추가)")
|
||||
|
||||
def _make_char_pr(self, id: int, style: StyleDefinition) -> str:
|
||||
"""charPr XML 생성 (한 줄로!)"""
|
||||
color = style.font_color.lstrip('#')
|
||||
font_id = "1" if style.font_bold else "0"
|
||||
|
||||
return f'<hh:charPr id="{id}" height="{style.font_size}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1"><hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:underline type="NONE" shape="SOLID" color="#000000"/><hh:strikeout shape="NONE" color="#000000"/><hh:outline type="NONE"/><hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/></hh:charPr>'
|
||||
|
||||
def _make_para_pr(self, id: int, style: StyleDefinition) -> str:
|
||||
"""paraPr XML 생성 (한 줄로!)"""
|
||||
# 개요 문단이면 type="OUTLINE", 아니면 type="NONE"
|
||||
# idRef="0"은 numbering id=1 (기본 번호 모양)을 참조
|
||||
if style.outline_level >= 0:
|
||||
heading = f'<hh:heading type="OUTLINE" idRef="0" level="{style.outline_level}"/>'
|
||||
else:
|
||||
heading = '<hh:heading type="NONE" idRef="0" level="0"/>'
|
||||
|
||||
return f'<hh:paraPr id="{id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0"><hh:align horizontal="{style.align}" vertical="BASELINE"/>{heading}<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:autoSpacing eAsianEng="0" eAsianNum="0"/><hh:margin><hc:intent value="{style.indent_first}" unit="HWPUNIT"/><hc:left value="{style.indent_left}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{style.space_before}" unit="HWPUNIT"/><hc:next value="{style.space_after}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="{style.line_spacing}" unit="HWPUNIT"/><hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>'
|
||||
|
||||
def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str:
|
||||
"""style XML 생성"""
|
||||
safe_name = name.replace('<', '<').replace('>', '>')
|
||||
return f'<hh:style id="{id}" type="PARA" name="{safe_name}" engName="" paraPrIDRef="{para_id}" charPrIDRef="{char_id}" nextStyleIDRef="{id}" langID="1042" lockForm="0"/>'
|
||||
|
||||
def _insert_before_tag(self, content: str, tag: str, insert_text: str) -> str:
|
||||
"""특정 태그 앞에 텍스트 삽입"""
|
||||
return content.replace(tag, insert_text + tag)
|
||||
|
||||
def _update_item_counts(self, content: str) -> str:
|
||||
"""itemCnt 속성 업데이트"""
|
||||
# charProperties itemCnt
|
||||
char_count = content.count('<hh:charPr ')
|
||||
content = re.sub(
|
||||
r'<hh:charProperties itemCnt="(\d+)"',
|
||||
f'<hh:charProperties itemCnt="{char_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
# paraProperties itemCnt
|
||||
para_count = content.count('<hh:paraPr ')
|
||||
content = re.sub(
|
||||
r'<hh:paraProperties itemCnt="(\d+)"',
|
||||
f'<hh:paraProperties itemCnt="{para_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
# styles itemCnt
|
||||
style_count = content.count('<hh:style ')
|
||||
content = re.sub(
|
||||
r'<hh:styles itemCnt="(\d+)"',
|
||||
f'<hh:styles itemCnt="{style_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
# 🆕 numberings itemCnt
|
||||
numbering_count = content.count('<hh:numbering ')
|
||||
content = re.sub(
|
||||
r'<hh:numberings itemCnt="(\d+)"',
|
||||
f'<hh:numberings itemCnt="{numbering_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
def _replace_default_numbering(self, content: str) -> str:
|
||||
"""numbering id=1의 패턴을 우리 패턴으로 교체"""
|
||||
# 우리가 원하는 개요 번호 패턴
|
||||
new_patterns = [
|
||||
{'level': '1', 'format': 'DIGIT', 'pattern': '제^1장'},
|
||||
{'level': '2', 'format': 'DIGIT', 'pattern': '^1.^2'},
|
||||
{'level': '3', 'format': 'DIGIT', 'pattern': '^1.^2.^3'},
|
||||
{'level': '4', 'format': 'HANGUL_SYLLABLE', 'pattern': '^4.'},
|
||||
{'level': '5', 'format': 'DIGIT', 'pattern': '^5)'},
|
||||
{'level': '6', 'format': 'HANGUL_SYLLABLE', 'pattern': '^6)'},
|
||||
{'level': '7', 'format': 'CIRCLED_DIGIT', 'pattern': '^7'},
|
||||
]
|
||||
|
||||
# numbering id="1" 찾기
|
||||
match = re.search(r'(<hh:numbering id="1"[^>]*>)(.*?)(</hh:numbering>)', content, re.DOTALL)
|
||||
if not match:
|
||||
print(" [경고] numbering id=1 없음, 교체 건너뜀")
|
||||
return content
|
||||
|
||||
numbering_content = match.group(2)
|
||||
|
||||
for np in new_patterns:
|
||||
level = np['level']
|
||||
fmt = np['format']
|
||||
pattern = np['pattern']
|
||||
|
||||
# 해당 level의 paraHead 찾아서 교체
|
||||
def replace_parahead(m):
|
||||
tag = m.group(0)
|
||||
# numFormat 변경
|
||||
tag = re.sub(r'numFormat="[^"]*"', f'numFormat="{fmt}"', tag)
|
||||
# 패턴(텍스트 내용) 변경
|
||||
tag = re.sub(r'>([^<]*)</hh:paraHead>', f'>{pattern}</hh:paraHead>', tag)
|
||||
return tag
|
||||
|
||||
numbering_content = re.sub(
|
||||
rf'<hh:paraHead[^>]*level="{level}"[^>]*>.*?</hh:paraHead>',
|
||||
replace_parahead,
|
||||
numbering_content
|
||||
)
|
||||
|
||||
new_content = match.group(1) + numbering_content + match.group(3)
|
||||
print(" [INFO] numbering id=1 패턴 교체 완료 (제^1장, ^1.^2, ^1.^2.^3...)")
|
||||
return content.replace(match.group(0), new_content)
|
||||
|
||||
def _adjust_tables(self, content: str) -> str:
|
||||
"""표 셀 크기 자동 조정
|
||||
|
||||
1. 행 높이: 최소 800 hwpunit (내용 잘림 방지)
|
||||
2. 열 너비: 표 전체 너비를 열 개수로 균등 분배 (또는 첫 열 좁게)
|
||||
"""
|
||||
|
||||
def adjust_table(match):
|
||||
tbl = match.group(0)
|
||||
|
||||
# 표 전체 너비 추출
|
||||
sz_match = re.search(r'<hp:sz width="(\d+)"', tbl)
|
||||
table_width = int(sz_match.group(1)) if sz_match else 47624
|
||||
|
||||
# 열 개수 추출
|
||||
col_match = re.search(r'colCnt="(\d+)"', tbl)
|
||||
col_cnt = int(col_match.group(1)) if col_match else 4
|
||||
|
||||
# 열 너비 계산 (첫 열은 30%, 나머지 균등)
|
||||
first_col_width = int(table_width * 0.25)
|
||||
other_col_width = (table_width - first_col_width) // (col_cnt - 1) if col_cnt > 1 else table_width
|
||||
|
||||
# 행 높이 최소값 설정
|
||||
min_height = 800 # 약 8mm
|
||||
|
||||
# 셀 크기 조정
|
||||
col_idx = [0] # closure용
|
||||
|
||||
def adjust_cell_sz(cell_match):
|
||||
width = int(cell_match.group(1))
|
||||
height = int(cell_match.group(2))
|
||||
|
||||
# 높이 조정
|
||||
new_height = max(height, min_height)
|
||||
|
||||
return f'<hp:cellSz width="{width}" height="{new_height}"/>'
|
||||
|
||||
tbl = re.sub(
|
||||
r'<hp:cellSz width="(\d+)" height="(\d+)"/>',
|
||||
adjust_cell_sz,
|
||||
tbl
|
||||
)
|
||||
|
||||
return tbl
|
||||
|
||||
return re.sub(r'<hp:tbl[^>]*>.*?</hp:tbl>', adjust_table, content, flags=re.DOTALL)
|
||||
|
||||
def _inject_section_styles(self, role_positions: Dict[str, List[tuple]]):
|
||||
"""section*.xml에 styleIDRef 매핑 (텍스트 매칭 방식)"""
|
||||
contents_dir = self.temp_dir / "Contents"
|
||||
|
||||
# 🔍 디버그: role_to_style_id 확인
|
||||
print(f" [DEBUG] role_to_style_id: {self.role_to_style_id}")
|
||||
|
||||
# section 파일들 찾기
|
||||
section_files = sorted(contents_dir.glob("section*.xml"))
|
||||
print(f" [DEBUG] section files: {[f.name for f in section_files]}")
|
||||
|
||||
total_modified = 0
|
||||
|
||||
for section_file in section_files:
|
||||
print(f" [DEBUG] Processing: {section_file.name}")
|
||||
original_content = section_file.read_text(encoding='utf-8')
|
||||
print(f" [DEBUG] File size: {len(original_content)} bytes")
|
||||
|
||||
content = original_content # 작업용 복사본
|
||||
|
||||
# 🆕 머리말/꼬리말 영역 보존 (placeholder로 교체)
|
||||
header_footer_map = {}
|
||||
placeholder_idx = 0
|
||||
|
||||
def save_header_footer(match):
|
||||
nonlocal placeholder_idx
|
||||
key = f"__HF_PLACEHOLDER_{placeholder_idx}__"
|
||||
header_footer_map[key] = match.group(0)
|
||||
placeholder_idx += 1
|
||||
return key
|
||||
|
||||
# 머리말/꼬리말 임시 교체
|
||||
content = re.sub(r'<hp:header[^>]*>.*?</hp:header>', save_header_footer, content, flags=re.DOTALL)
|
||||
content = re.sub(r'<hp:footer[^>]*>.*?</hp:footer>', save_header_footer, content, flags=re.DOTALL)
|
||||
|
||||
# 모든 <hp:p> 태그와 내부 텍스트 추출
|
||||
para_pattern = r'(<hp:p [^>]*>)(.*?)(</hp:p>)'
|
||||
|
||||
section_modified = 0
|
||||
|
||||
def replace_style(match):
|
||||
nonlocal total_modified, section_modified
|
||||
open_tag = match.group(1)
|
||||
inner = match.group(2)
|
||||
close_tag = match.group(3)
|
||||
|
||||
# 텍스트 추출 (태그 제거)
|
||||
text = re.sub(r'<[^>]+>', '', inner).strip()
|
||||
if not text:
|
||||
return match.group(0)
|
||||
|
||||
# 텍스트 앞부분으로 역할 판단
|
||||
text_start = text[:50] # 처음 50자로 판단
|
||||
|
||||
matched_role = None
|
||||
matched_style_id = None
|
||||
matched_para_id = None
|
||||
matched_char_id = None
|
||||
|
||||
# 제목 패턴 매칭 (앞에 특수문자 허용)
|
||||
# Unicode: ■\u25a0 ▸\u25b8 ◆\u25c6 ▶\u25b6 ●\u25cf ○\u25cb ▪\u25aa ►\u25ba ☞\u261e ★\u2605 ※\u203b ·\u00b7
|
||||
prefix = r'^[\u25a0\u25b8\u25c6\u25b6\u25cf\u25cb\u25aa\u25ba\u261e\u2605\u203b\u00b7\s]*'
|
||||
|
||||
# 🆕 FIGURE_CAPTION: "[그림 1-1]", "[그림 1-2]" 등 (가장 먼저 체크!)
|
||||
# 그림 = \uadf8\ub9bc
|
||||
if re.match(r'^\[\uadf8\ub9bc\s*[\d-]+\]', text_start):
|
||||
matched_role = 'FIGURE_CAPTION'
|
||||
# 🆕 TABLE_CAPTION: "<표 1-1>", "[표 1-1]" 등
|
||||
# 표 = \ud45c
|
||||
elif re.match(r'^[<\[]\ud45c\s*[\d-]+[>\]]', text_start):
|
||||
matched_role = 'TABLE_CAPTION'
|
||||
# H1: "제1장", "1 개요" 등
|
||||
elif re.match(prefix + r'\uc81c?\s*\d+\uc7a5?\s', text_start) or re.match(prefix + r'[1-9]\s+[\uac00-\ud7a3]', text_start):
|
||||
matched_role = 'H1'
|
||||
# H3: "1.1.1 " (H2보다 먼저 체크!)
|
||||
elif re.match(prefix + r'\d+\.\d+\.\d+\s', text_start):
|
||||
matched_role = 'H3'
|
||||
# H2: "1.1 "
|
||||
elif re.match(prefix + r'\d+\.\d+\s', text_start):
|
||||
matched_role = 'H2'
|
||||
# H4: "가. "
|
||||
elif re.match(prefix + r'[\uac00-\ud7a3]\.\s', text_start):
|
||||
matched_role = 'H4'
|
||||
# H5: "1) "
|
||||
elif re.match(prefix + r'\d+\)\s', text_start):
|
||||
matched_role = 'H5'
|
||||
# H6: "(1) " 또는 "가) "
|
||||
elif re.match(prefix + r'\(\d+\)\s', text_start):
|
||||
matched_role = 'H6'
|
||||
elif re.match(prefix + r'[\uac00-\ud7a3]\)\s', text_start):
|
||||
matched_role = 'H6'
|
||||
# LIST_ITEM: "○ ", "● ", "• " 등
|
||||
elif re.match(r'^[\u25cb\u25cf\u25e6\u2022\u2023\u25b8]\s', text_start):
|
||||
matched_role = 'LIST_ITEM'
|
||||
elif re.match(r'^[-\u2013\u2014]\s', text_start):
|
||||
matched_role = 'LIST_ITEM'
|
||||
|
||||
# 매칭된 역할이 있고 스타일 ID가 있으면 적용
|
||||
if matched_role and matched_role in self.role_to_style_id:
|
||||
matched_style_id = self.role_to_style_id[matched_role]
|
||||
matched_para_id = self.role_to_para_id[matched_role]
|
||||
matched_char_id = self.role_to_char_id[matched_role]
|
||||
elif 'BODY' in self.role_to_style_id and len(text) > 20:
|
||||
# 긴 텍스트는 본문으로 간주
|
||||
matched_role = 'BODY'
|
||||
matched_style_id = self.role_to_style_id['BODY']
|
||||
matched_para_id = self.role_to_para_id['BODY']
|
||||
matched_char_id = self.role_to_char_id['BODY']
|
||||
|
||||
if matched_style_id:
|
||||
# 1. hp:p 태그의 styleIDRef 변경
|
||||
if 'styleIDRef="' in open_tag:
|
||||
new_open = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{matched_style_id}"', open_tag)
|
||||
else:
|
||||
new_open = open_tag.replace('<hp:p ', f'<hp:p styleIDRef="{matched_style_id}" ')
|
||||
|
||||
# 2. hp:p 태그의 paraPrIDRef도 변경! (스타일의 paraPrIDRef와 일치!)
|
||||
new_open = re.sub(r'paraPrIDRef="[^"]*"', f'paraPrIDRef="{matched_para_id}"', new_open)
|
||||
|
||||
# 3. inner에서 hp:run의 charPrIDRef도 변경! (스타일의 charPrIDRef와 일치!)
|
||||
new_inner = re.sub(r'(<hp:run[^>]*charPrIDRef=")[^"]*(")', f'\\g<1>{matched_char_id}\\2', inner)
|
||||
|
||||
# 🆕 4. 개요 문단이면 수동 번호 제거 (자동 번호가 붙으니까!)
|
||||
if matched_role in ROLE_STYLES and ROLE_STYLES[matched_role].outline_level >= 0:
|
||||
new_inner = self._remove_manual_numbering(new_inner, matched_role)
|
||||
|
||||
total_modified += 1
|
||||
section_modified += 1
|
||||
return new_open + new_inner + close_tag
|
||||
|
||||
return match.group(0)
|
||||
|
||||
new_content = re.sub(para_pattern, replace_style, content, flags=re.DOTALL)
|
||||
|
||||
# 🆕 표 크기 자동 조정
|
||||
new_content = self._adjust_tables(new_content)
|
||||
|
||||
# 🆕 outlineShapeIDRef를 1로 변경 (우리가 교체한 numbering id=1 사용)
|
||||
new_content = re.sub(
|
||||
r'outlineShapeIDRef="[^"]*"',
|
||||
'outlineShapeIDRef="1"',
|
||||
new_content
|
||||
)
|
||||
|
||||
|
||||
# 🆕 머리말/꼬리말 복원
|
||||
for key, original in header_footer_map.items():
|
||||
new_content = new_content.replace(key, original)
|
||||
|
||||
print(f" [DEBUG] {section_file.name}: {section_modified} paras modified, content changed: {new_content != original_content}")
|
||||
|
||||
if new_content != original_content:
|
||||
section_file.write_text(new_content, encoding='utf-8')
|
||||
print(f" -> {section_file.name} saved")
|
||||
|
||||
print(f" -> Total {total_modified} paragraphs styled")
|
||||
|
||||
def _update_para_style(self, content: str, para_idx: int, style_id: int) -> str:
|
||||
"""특정 인덱스의 문단 styleIDRef 변경"""
|
||||
# <hp:p ...> 태그들 찾기
|
||||
pattern = r'<hp:p\s[^>]*>'
|
||||
matches = list(re.finditer(pattern, content))
|
||||
|
||||
if para_idx >= len(matches):
|
||||
return content
|
||||
|
||||
match = matches[para_idx]
|
||||
old_tag = match.group(0)
|
||||
|
||||
# styleIDRef 속성 변경 또는 추가
|
||||
if 'styleIDRef=' in old_tag:
|
||||
new_tag = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{style_id}"', old_tag)
|
||||
else:
|
||||
# 속성 추가
|
||||
new_tag = old_tag.replace('<hp:p ', f'<hp:p styleIDRef="{style_id}" ')
|
||||
|
||||
return content[:match.start()] + new_tag + content[match.end():]
|
||||
|
||||
def _remove_manual_numbering(self, inner: str, role: str) -> str:
|
||||
"""🆕 개요 문단에서 수동 번호 제거 (자동 번호가 붙으니까!)
|
||||
|
||||
HTML에서 "제1장 DX 개요" → "DX 개요" (자동으로 "제1장" 붙음)
|
||||
HTML에서 "1.1 측량 DX" → "측량 DX" (자동으로 "1.1" 붙음)
|
||||
"""
|
||||
# 역할별 번호 패턴
|
||||
patterns = {
|
||||
'H1': r'^(제\s*\d+\s*장\s*)', # "제1장 " → 제거
|
||||
'H2': r'^(\d+\.\d+\s+)', # "1.1 " → 제거
|
||||
'H3': r'^(\d+\.\d+\.\d+\s+)', # "1.1.1 " → 제거
|
||||
'H4': r'^([가-힣]\.\s+)', # "가. " → 제거
|
||||
'H5': r'^(\d+\)\s+)', # "1) " → 제거
|
||||
'H6': r'^([가-힣]\)\s+|\(\d+\)\s+)', # "가) " 또는 "(1) " → 제거
|
||||
'H7': r'^([①②③④⑤⑥⑦⑧⑨⑩]+\s*)', # "① " → 제거
|
||||
}
|
||||
|
||||
if role not in patterns:
|
||||
return inner
|
||||
|
||||
pattern = patterns[role]
|
||||
|
||||
# <hp:t> 태그 내 텍스트에서 번호 제거
|
||||
def remove_number(match):
|
||||
text = match.group(1)
|
||||
# 첫 번째 <hp:t> 내용에서만 번호 제거
|
||||
new_text = re.sub(pattern, '', text, count=1)
|
||||
return f'<hp:t>{new_text}</hp:t>'
|
||||
|
||||
# 첫 번째 hp:t 태그만 처리
|
||||
new_inner = re.sub(r'<hp:t>([^<]*)</hp:t>', remove_number, inner, count=1)
|
||||
|
||||
return new_inner
|
||||
|
||||
def _repack_hwpx(self, output_path: str):
|
||||
"""HWPX 재압축"""
|
||||
print(f" [DEBUG] Repacking to: {output_path}")
|
||||
print(f" [DEBUG] Source dir: {self.temp_dir}")
|
||||
|
||||
# 압축 전 section 파일 크기 확인
|
||||
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
|
||||
sec_path = self.temp_dir / "Contents" / sec
|
||||
if sec_path.exists():
|
||||
print(f" [DEBUG] {sec} size before zip: {sec_path.stat().st_size} bytes")
|
||||
|
||||
# 🆕 임시 파일에 먼저 저장 (원본 파일 잠금 문제 회피)
|
||||
temp_output = output_path + ".tmp"
|
||||
|
||||
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# mimetype은 압축 없이 첫 번째로
|
||||
mimetype_path = self.temp_dir / "mimetype"
|
||||
if mimetype_path.exists():
|
||||
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# 나머지 파일들
|
||||
file_count = 0
|
||||
for root, dirs, files in os.walk(self.temp_dir):
|
||||
for file in files:
|
||||
if file == "mimetype":
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
arcname = file_path.relative_to(self.temp_dir)
|
||||
zf.write(file_path, arcname)
|
||||
file_count += 1
|
||||
|
||||
print(f" [DEBUG] Total files zipped: {file_count}")
|
||||
|
||||
# 🆕 원본 삭제 후 임시 파일을 원본 이름으로 변경
|
||||
import time
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if os.path.exists(output_path):
|
||||
os.remove(output_path)
|
||||
os.rename(temp_output, output_path)
|
||||
break
|
||||
except PermissionError:
|
||||
print(f" [DEBUG] 파일 잠금 대기 중... ({attempt + 1}/3)")
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
# 3번 시도 실패 시 임시 파일 이름으로 유지
|
||||
print(f" [경고] 원본 덮어쓰기 실패, 임시 파일 사용: {temp_output}")
|
||||
output_path = temp_output
|
||||
|
||||
# 압축 후 결과 확인
|
||||
print(f" [DEBUG] Output file size: {Path(output_path).stat().st_size} bytes")
|
||||
|
||||
|
||||
def inject_styles_to_hwpx(hwpx_path: str, elements: list) -> str:
|
||||
"""
|
||||
편의 함수: StyledElement 리스트로부터 역할 위치 추출 후 스타일 주입
|
||||
|
||||
Args:
|
||||
hwpx_path: HWPX 파일 경로
|
||||
elements: StyleAnalyzer의 StyledElement 리스트
|
||||
|
||||
Returns:
|
||||
수정된 HWPX 파일 경로
|
||||
"""
|
||||
# 역할별 위치 수집
|
||||
# 참고: 현재는 section 0, para 순서대로 가정
|
||||
role_positions: Dict[str, List[tuple]] = {}
|
||||
|
||||
for idx, elem in enumerate(elements):
|
||||
role = elem.role
|
||||
if role not in role_positions:
|
||||
role_positions[role] = []
|
||||
# (section_idx, para_idx) - 현재는 section 0 가정
|
||||
role_positions[role].append((0, idx))
|
||||
|
||||
injector = HwpxStyleInjector()
|
||||
return injector.inject(hwpx_path, role_positions)
|
||||
|
||||
|
||||
# 테스트
|
||||
if __name__ == "__main__":
|
||||
# 테스트용
|
||||
test_positions = {
|
||||
'H1': [(0, 0), (0, 5)],
|
||||
'H2': [(0, 1), (0, 6)],
|
||||
'BODY': [(0, 2), (0, 3), (0, 4)],
|
||||
}
|
||||
|
||||
# injector = HwpxStyleInjector()
|
||||
# injector.inject("test.hwpx", test_positions)
|
||||
print("HwpxStyleInjector 모듈 로드 완료")
|
||||
174
03. Code/geulbeot_10th/converters/hwpx_table_injector.py
Normal file
174
03. Code/geulbeot_10th/converters/hwpx_table_injector.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HWPX 표 열 너비 수정기 v2
|
||||
표 생성 후 HWPX 파일을 직접 수정하여 열 너비 적용
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
import re
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# mm → HWPML 단위 변환 (1mm ≈ 283.46 HWPML units)
|
||||
MM_TO_HWPML = 7200 / 25.4 # ≈ 283.46
|
||||
|
||||
|
||||
def inject_table_widths(hwpx_path: str, table_widths_list: list):
|
||||
"""
|
||||
HWPX 파일의 표 열 너비를 수정
|
||||
|
||||
Args:
|
||||
hwpx_path: HWPX 파일 경로
|
||||
table_widths_list: [[w1, w2, w3], [w1, w2], ...] 형태 (mm 단위)
|
||||
"""
|
||||
if not table_widths_list:
|
||||
print(" [INFO] 수정할 표 없음")
|
||||
return
|
||||
|
||||
print(f"📐 HWPX 표 열 너비 수정 시작... ({len(table_widths_list)}개 표)")
|
||||
|
||||
# HWPX 압축 해제
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="hwpx_table_"))
|
||||
|
||||
with zipfile.ZipFile(hwpx_path, 'r') as zf:
|
||||
zf.extractall(temp_dir)
|
||||
|
||||
# section*.xml 파일들에서 표 찾기
|
||||
contents_dir = temp_dir / "Contents"
|
||||
|
||||
table_idx = 0
|
||||
total_modified = 0
|
||||
|
||||
for section_file in sorted(contents_dir.glob("section*.xml")):
|
||||
with open(section_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
original_content = content
|
||||
|
||||
# 모든 표(<hp:tbl>...</hp:tbl>) 찾기
|
||||
tbl_pattern = re.compile(r'(<hp:tbl\b[^>]*>)(.*?)(</hp:tbl>)', re.DOTALL)
|
||||
|
||||
def process_table(match):
|
||||
nonlocal table_idx, total_modified
|
||||
|
||||
if table_idx >= len(table_widths_list):
|
||||
return match.group(0)
|
||||
|
||||
tbl_open = match.group(1)
|
||||
tbl_content = match.group(2)
|
||||
tbl_close = match.group(3)
|
||||
|
||||
col_widths_mm = table_widths_list[table_idx]
|
||||
col_widths_hwpml = [int(w * MM_TO_HWPML) for w in col_widths_mm]
|
||||
|
||||
# 표 전체 너비 수정 (hp:sz width="...")
|
||||
total_width = int(sum(col_widths_mm) * MM_TO_HWPML)
|
||||
tbl_content = re.sub(
|
||||
r'(<hp:sz\s+width=")(\d+)(")',
|
||||
lambda m: f'{m.group(1)}{total_width}{m.group(3)}',
|
||||
tbl_content,
|
||||
count=1
|
||||
)
|
||||
|
||||
# 각 셀의 cellSz width 수정
|
||||
# 방법: colAddr별로 너비 매핑
|
||||
def replace_cell_width(tc_match):
|
||||
tc_content = tc_match.group(0)
|
||||
|
||||
# colAddr 추출
|
||||
col_addr_match = re.search(r'<hp:cellAddr\s+colAddr="(\d+)"', tc_content)
|
||||
if not col_addr_match:
|
||||
return tc_content
|
||||
|
||||
col_idx = int(col_addr_match.group(1))
|
||||
if col_idx >= len(col_widths_hwpml):
|
||||
return tc_content
|
||||
|
||||
new_width = col_widths_hwpml[col_idx]
|
||||
|
||||
# cellSz width 교체
|
||||
tc_content = re.sub(
|
||||
r'(<hp:cellSz\s+width=")(\d+)(")',
|
||||
lambda m: f'{m.group(1)}{new_width}{m.group(3)}',
|
||||
tc_content
|
||||
)
|
||||
|
||||
return tc_content
|
||||
|
||||
# 각 <hp:tc>...</hp:tc> 블록 처리
|
||||
tbl_content = re.sub(
|
||||
r'<hp:tc\b[^>]*>.*?</hp:tc>',
|
||||
replace_cell_width,
|
||||
tbl_content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
print(f" ✅ 표 #{table_idx + 1}: {col_widths_mm} mm → HWPML 적용")
|
||||
table_idx += 1
|
||||
total_modified += 1
|
||||
|
||||
return tbl_open + tbl_content + tbl_close
|
||||
|
||||
# 표 처리
|
||||
new_content = tbl_pattern.sub(process_table, content)
|
||||
|
||||
# 변경사항 있으면 저장
|
||||
if new_content != original_content:
|
||||
with open(section_file, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
print(f" → {section_file.name} 저장됨")
|
||||
|
||||
# 다시 압축
|
||||
repack_hwpx(temp_dir, hwpx_path)
|
||||
|
||||
# 임시 폴더 삭제
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
print(f" ✅ 총 {total_modified}개 표 열 너비 수정 완료")
|
||||
|
||||
|
||||
def repack_hwpx(source_dir: Path, output_path: str):
|
||||
"""HWPX 파일 다시 압축"""
|
||||
import os
|
||||
import time
|
||||
|
||||
temp_output = output_path + ".tmp"
|
||||
|
||||
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# mimetype은 압축 없이 첫 번째로
|
||||
mimetype_path = source_dir / "mimetype"
|
||||
if mimetype_path.exists():
|
||||
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# 나머지 파일들
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
for file in files:
|
||||
if file == "mimetype":
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
arcname = file_path.relative_to(source_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
# 원본 교체
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if os.path.exists(output_path):
|
||||
os.remove(output_path)
|
||||
os.rename(temp_output, output_path)
|
||||
break
|
||||
except PermissionError:
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
# 테스트용
|
||||
if __name__ == "__main__":
|
||||
test_widths = [
|
||||
[18.2, 38.9, 42.8, 70.1],
|
||||
[19.9, 79.6, 70.5],
|
||||
[28.7, 81.4, 59.9],
|
||||
[19.2, 61.4, 89.5],
|
||||
]
|
||||
|
||||
hwpx_path = r"C:\Users\User\AppData\Local\Temp\geulbeot_output.hwpx"
|
||||
inject_table_widths(hwpx_path, test_widths)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user