dohyune commited on
Commit
fbfae11
·
verified ·
1 Parent(s): 715aec7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +223 -204
app.py CHANGED
@@ -1,221 +1,240 @@
1
  import streamlit as st
2
  import pandas as pd
3
- import io, zipfile, re, html, json
4
-
5
- st.set_page_config(page_title="📦 박스라벨 자동 생성기 (HWPX 필드 평문화)", layout="wide")
6
- st.title("📦 박스라벨 자동 생성기 — HWPX **필드 제거/평문화 방식**")
7
-
8
- # ================= 공통 유틸 =================
9
- def compute_year_range(series: pd.Series) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  s = series.astype(str).fillna("")
11
- valid = s[~s.isin(["", "0", "0000"])]
12
- if len(valid) == 0:
13
  return "0000-0000"
14
- valid_int = pd.to_numeric(valid, errors="coerce").dropna().astype(int)
15
- if len(valid_int) == 0:
16
  return "0000-0000"
17
- return f"{valid_int.min():04d}-{valid_int.max():04d}"
18
 
19
- def build_merged_df(df: pd.DataFrame) -> pd.DataFrame:
20
  df = df.copy()
21
  df["박스번호"] = df["박스번호"].astype(str).str.zfill(4)
22
- if "제목" in df.columns:
23
- df["제목"] = df["제목"].astype(str)
24
-
25
- # 생산연도(범위) = 종료연도 그룹 범위
26
  if "종료연도" in df.columns:
27
- prod_df = df.groupby("박스번호")["종료연도"].apply(compute_year_range).reset_index()
28
- prod_df.columns = ["박스번호", "생산연도"]
29
  else:
30
- prod_df = pd.DataFrame({"박스번호": df["박스번호"].unique(), "생산연도": "0000-0000"})
31
-
32
- # 목록(관리번호 + 제목)
33
  has_mgmt = "관리번호" in df.columns
34
  list_rows = []
35
  for box, g in df.groupby("박스번호"):
36
- lines = [f"- {r['관리번호']} {r['제목']}" if has_mgmt else f"- {r['제목']}"
37
  for _, r in g.iterrows()]
38
- list_rows.append({"박스번호": box, "목록": "\r\n".join(lines)})
39
  list_df = pd.DataFrame(list_rows)
40
-
41
- meta_cols = ["박스번호","종료연도","보존기간","단위업무","기록물철","제목"]
42
- meta_exist = [c for c in meta_cols if c in df.columns]
43
- meta_df = df.groupby("박스번호", as_index=False).first()[meta_exist] if meta_exist \
44
- else pd.DataFrame({"박스번호": df["박스번호"].unique()})
45
-
46
- return meta_df.merge(list_df, on="박스번호", how="left").merge(prod_df, on="박스번호", how="left")
47
-
48
- def _runs_plain(text: str) -> str:
49
- return f"<hp:run><hp:t>{html.escape('' if text is None else str(text))}</hp:t></hp:run>"
50
-
51
- def _runs_list(text: str) -> str:
52
- if text is None: return ""
53
- lines = str(text).replace("\r\n", "\n").split("\n")
54
- parts = []
55
- for i, ln in enumerate(lines):
56
- if i > 0:
57
- parts.append("<hp:lineBreak/>")
58
- parts.append(f"<hp:run><hp:t>{html.escape(ln)}</hp:t></hp:run>")
59
- return "".join(parts)
60
-
61
- # =============== HWPX 쓰기 (mimetype 맨앞/무압축) ===============
62
- def write_hwpx_like_src(zin: zipfile.ZipFile, writer_fn) -> bytes:
63
- out = io.BytesIO()
64
- zout = zipfile.ZipFile(out, "w")
65
-
66
- if "mimetype" in zin.namelist():
67
- zi = zipfile.ZipInfo("mimetype")
68
- zi.compress_type = zipfile.ZIP_STORED
69
- zout.writestr(zi, zin.read("mimetype"))
70
-
71
- for e in zin.infolist():
72
- if e.filename == "mimetype":
73
- continue
74
- data = zin.read(e.filename)
75
- if e.filename.startswith("Contents/") and e.filename.endswith(".xml"):
76
- try:
77
- s = data.decode("utf-8", errors="ignore")
78
- s2 = writer_fn(e.filename, s)
79
- data = s2.encode("utf-8")
80
- except Exception:
81
- pass
82
- zi = zipfile.ZipInfo(e.filename)
83
- zi.compress_type = zipfile.ZIP_DEFLATED
84
- zout.writestr(zi, data)
85
-
86
- zout.close(); out.seek(0)
87
- return out.getvalue()
88
-
89
- # =============== 필드 평문화(제거) 치환 ===============
90
- # 한글은 필드가 보통 이렇게 들어갑니다:
91
- # <hp:run> ... <hp:fieldBegin name="키" .../> ... </hp:run>
92
- # (중간에 여러 run/텍스트)
93
- # <hp:run> ... <hp:fieldEnd/> ... </hp:run>
94
- # => 아래 정규식으로 "fieldBegin run ~ fieldEnd run" 전체를 값 run들로 대체합니다.
95
- FIELD_RANGE_RE_TMPL = (
96
- r'(<hp:run[^>]*>[^<]*'
97
- r'<hp:fieldBegin[^>]*name="{name}"[^>]*/>'
98
- r'.*?</hp:run>)'
99
- r'(.*?)'
100
- r'(<hp:run[^>]*>.*?<hp:fieldEnd[^>]*/>.*?</hp:run>)'
101
- )
102
-
103
- def apply_field_flatten(hwpx_bytes: bytes, mapping: dict, collect_debug=False):
104
- dbg = {"mode":"field-flatten","files_touched":[], "field_hits":{}} if collect_debug else None
105
- zin = zipfile.ZipFile(io.BytesIO(hwpx_bytes), "r")
106
-
107
- # 실제 존재하는 name만 추출
108
- present = set()
109
- for e in zin.infolist():
110
- if e.filename.startswith("Contents/") and e.filename.endswith(".xml"):
111
- try:
112
- s = zin.read(e.filename).decode("utf-8", errors="ignore")
113
- for k in mapping.keys():
114
- if f'name="{k}"' in s:
115
- present.add(k)
116
- except:
117
- pass
118
-
119
- def writer(fname: str, xml: str) -> str:
120
- changed = False
121
- for k in present:
122
- val = mapping.get(k, "")
123
- is_list = bool(re.match(r"^(목록|list)\d+$", k, re.IGNORECASE))
124
- replacement_runs = _runs_list(val) if is_list else _runs_plain(val)
125
-
126
- pat = re.compile(FIELD_RANGE_RE_TMPL.format(name=re.escape(k)), re.DOTALL)
127
- xml2, n = pat.subn(replacement_runs, xml)
128
- if n:
129
- changed = True
130
- xml = xml2
131
- if dbg: dbg["field_hits"][k] = dbg["field_hits"].get(k, 0) + 1
132
-
133
- if changed and dbg and fname not in dbg["files_touched"]:
134
- dbg["files_touched"].append(fname)
135
- return xml
136
-
137
- out = write_hwpx_like_src(zin, writer)
138
- zin.close()
139
- return (out, dbg) if collect_debug else (out, None)
140
-
141
- # ================= UI =================
142
- with st.expander("사용법", expanded=True):
143
- st.markdown("""
144
- - 템플릿은 **한글 필드컨트롤**이어야 합니다. (예: `name="박스번호1"`)
145
- - 이 앱은 필드 구간을 **평문화(필드 제거)** 하여 값 run들로 바꿉니다. → 한글 뷰어에서 **항상 보임**.
146
- - 라벨 한 페이지에 N개면, 필드명은 `박스번호1..N`, `종료연도1..N`, `보존기간1..N`, `단위업무1..N`, `기록물철1..N`, `목록1..N`.
147
- """)
148
-
149
- tpl_file = st.file_uploader("📄 HWPX 템플릿 업로드", type=["hwpx"])
150
- batch_size = st.number_input("템플릿의 라벨 세트 개수 (한 페이지 N개)", min_value=1, max_value=12, value=3, step=1)
151
- data_file = st.file_uploader("📊 데이터 업로드 (Excel/CSV)", type=["xlsx","xls","csv"])
152
-
153
- if tpl_file and data_file:
154
- tpl_bytes = tpl_file.read()
155
- df = pd.read_csv(data_file) if data_file.name.lower().endswith(".csv") else pd.read_excel(data_file)
156
-
157
- if "박스번호" not in df.columns:
158
- st.error("❌ 필수 컬럼 '박스번호'가 없습니다.")
159
- st.stop()
160
-
161
- st.success("✅ 위치 매핑 완료 (엑셀 측)")
162
  st.dataframe(df.head(10), use_container_width=True)
163
 
164
- merged = build_merged_df(df)
165
- box_list = merged["박스번호"].astype(str).str.zfill(4).unique().tolist()
166
-
167
- st.subheader("🔎 업로드된 박스번호 목록")
168
- st.write(f"총 **{len(box_list)}**개")
169
- st.dataframe(pd.DataFrame({"박스번호": box_list}), use_container_width=True, height=240)
170
-
171
- selected = st.multiselect("생성할 박스번호 선택 (비우면 전체 생성)", options=box_list)
172
- work = merged[merged["박스번호"].isin(selected)] if selected else merged
173
- rows = work.sort_values("박스번호").to_dict(orient="records")
174
-
175
- # 1페이지 프리뷰
176
- st.subheader("🧪 1페이지 매핑 프리뷰")
177
- keys = ["박스번호","종료연도","보존기간","단위업무","기록물철","목록"]
178
- n = int(batch_size)
179
- preview = {}
180
- for i in range(n):
181
- if i < len(rows):
182
- r = rows[i]
183
- for k in keys:
184
- preview[f"{k}{i+1}"] = r.get("생산연도","") if k=="종료연도" else r.get(k,"")
185
- else:
186
- for k in keys:
187
- preview[f"{k}{i+1}"] = ""
188
- st.dataframe(
189
- pd.DataFrame([{"필드명":k, "값 앞부분":str(v)[:120]} for k,v in sorted(preview.items())]),
190
- use_container_width=True, height=320
191
- )
192
-
193
- if st.button("🚀 라벨 생성 (페이지별 HWPX ZIP)"):
194
- mem_zip = io.BytesIO()
195
- zout = zipfile.ZipFile(mem_zip, "w", zipfile.ZIP_DEFLATED)
196
- pages = (len(rows) + n - 1) // n
197
- all_dbg = []
198
-
199
- for p in range(pages):
200
- chunk = rows[p*n:(p+1)*n]
201
- mapping = {}
202
- for i in range(n):
203
- if i < len(chunk):
204
- r = chunk[i]
205
- for k in keys:
206
- mapping[f"{k}{i+1}"] = r.get("생산연도","") if k=="종료연도" else r.get(k,"")
207
- else:
208
- for k in keys:
209
- mapping[f"{k}{i+1}"] = ""
210
-
211
- out_hwpx, dbg = apply_field_flatten(tpl_bytes, mapping, collect_debug=True)
212
- all_dbg.append({"page": p+1, "stats": dbg})
213
- name = "_".join([r.get("박스번호","") for r in chunk]) if chunk else f"empty_{p+1}"
214
- zout.writestr(f"label_{name}.hwpx", out_hwpx)
215
-
216
- zout.close(); mem_zip.seek(0)
217
- st.download_button("⬇️ ZIP 다운로드", data=mem_zip, file_name="labels_by_page.zip", mime="application/zip")
218
- st.download_button("⬇️ 디버그(JSON)", data=json.dumps(all_dbg, ensure_ascii=False, indent=2),
219
- file_name="debug.json", mime="application/json")
220
-
221
- st.caption("필드 구간을 통째로 값 run들로 교체합니다. (필드 제거 → 값이 확실히 보입니다)")
 
1
  import streamlit as st
2
  import pandas as pd
3
+ from reportlab.pdfgen import canvas
4
+ from reportlab.pdfbase import pdfmetrics
5
+ from reportlab.pdfbase.ttfonts import TTFont
6
+ from reportlab.lib.pagesizes import A4
7
+ from reportlab.lib.units import mm
8
+ from io import BytesIO
9
+ import math
10
+
11
+ st.set_page_config(page_title="📦 박스라벨 PDF 출력기", layout="wide")
12
+ st.title("📦 박스라벨 PDF 출력기 (라벨 규격 커스텀 / 한국어 폰트 업로드)")
13
+
14
+ with st.expander("사용 방법", expanded=True):
15
+ st.markdown("""
16
+ 1. **엑셀/CSV 업로드** → 필수 컬럼: `박스번호` / 권장: `종료연도`, `보존기간`, `단위업무`, `기록물철`, `제목`, `관리번호`
17
+ 2. (선택) **TTF 폰트 업로드**(예: 나눔고딕, 본고딕, 맑은 고딕 등). 업로드 안 하면 기본 폰트 사용(영문 위주).
18
+ 3. **라벨 규격**(페이지 여백, 라벨 가로/세로, 행/열, 라벨 간격)을 입력.
19
+ 4. **텍스트 배치**(라벨 안쪽 패딩, 폰트 크기, 줄 간격 등) 조정.
20
+ 5. **PDF 생성** → 라벨 용지(Formtec 등)에 인쇄.
21
+ """)
22
+
23
+ # -----------------
24
+ # 데이터 로드
25
+ # -----------------
26
+ file = st.file_uploader("📊 데이터 업로드 (Excel/CSV)", type=["xlsx","xls","csv"])
27
+ df = None
28
+ if file:
29
+ if file.name.lower().endswith(".csv"):
30
+ df = pd.read_csv(file)
31
+ else:
32
+ df = pd.read_excel(file)
33
+
34
+ # 필수 컬럼 검사
35
+ if df is not None and "박스번호" not in df.columns:
36
+ st.error("❌ 필수 컬럼 '박스번호'가 없습니다.")
37
+ st.stop()
38
+
39
+ # -----------------
40
+ # 폰트 설정
41
+ # -----------------
42
+ st.subheader("🔤 폰트 설정")
43
+ font_file = st.file_uploader("한국어 폰트(TTF) 업로드 (예: NanumGothic.ttf / MalgunGothic.ttf)", type=["ttf"])
44
+ font_name = "BaseFont"
45
+ if font_file:
46
+ try:
47
+ font_bytes = font_file.read()
48
+ # 메모리 등록: ReportLab은 파일 경로가 필요 → 임시 파일 만들기보다 메모리 레지스터 트릭
49
+ # 하지만 TTFont는 파일 경로 요구 → 임시파일 저장
50
+ import tempfile
51
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ttf")
52
+ tmp.write(font_bytes); tmp.flush()
53
+ pdfmetrics.registerFont(TTFont("UserKorean", tmp.name))
54
+ font_name = "UserKorean"
55
+ st.success("✅ 폰트 등록 완료: UserKorean")
56
+ except Exception as e:
57
+ st.warning(f"폰트 등록 실패. 기본 폰트 사용합니다. (사유: {e})")
58
+ else:
59
+ # 내장 기본 폰트 (영문 중심)
60
+ font_name = "Helvetica"
61
+
62
+ # -----------------
63
+ # 라벨/페이지 레이아웃
64
+ # -----------------
65
+ st.subheader("📐 라벨 규격 (mm 단위)")
66
+ colA, colB, colC = st.columns(3)
67
+ with colA:
68
+ page_size = st.selectbox("페이지 크기", ["A4"], index=0)
69
+ with colB:
70
+ margin_left = st.number_input("왼쪽 여백(mm)", 5.0, 50.0, 10.0, 0.5)
71
+ margin_top = st.number_input("상단 여백(mm)", 5.0, 50.0, 10.0, 0.5)
72
+ with colC:
73
+ rows = st.number_input("행 수", 1, 20, 10, 1)
74
+ cols = st.number_input("열 수", 1, 10, 3, 1)
75
+
76
+ colD, colE, colF = st.columns(3)
77
+ with colD:
78
+ label_w = st.number_input("라벨 가로(mm)", 20.0, 210.0, 70.0, 0.5)
79
+ with colE:
80
+ label_h = st.number_input("라벨 세로(mm)", 10.0, 297.0, 25.0, 0.5)
81
+ with colF:
82
+ gap_x = st.number_input("가로 간격(mm)", 0.0, 20.0, 3.0, 0.5)
83
+ gap_y = st.number_input("세로 간격(mm)", 0.0, 20.0, 3.0, 0.5)
84
+
85
+ # -----------------
86
+ # 라벨 내부 텍스트 배치
87
+ # -----------------
88
+ st.subheader("🧱 라벨 내부 레이아웃")
89
+ col1, col2, col3 = st.columns(3)
90
+ with col1:
91
+ pad_x = st.number_input("내부 패딩 X(mm)", 0.0, 20.0, 2.0, 0.5)
92
+ pad_y = st.number_input("내부 패딩 Y(mm)", 0.0, 20.0, 2.0, 0.5)
93
+ with col2:
94
+ fs_big = st.number_input("폰트 크기(큰 제목)", 6, 40, 16, 1)
95
+ fs_mid = st.number_input("폰트 크기(중간)", 6, 40, 11, 1)
96
+ with col3:
97
+ fs_small = st.number_input("폰트 크기(작게/목록)", 6, 20, 9, 1)
98
+ line_gap = st.number_input("줄 간격(배수)", 0.8, 2.0, 1.2, 0.1)
99
+
100
+ st.caption("💡 Formtec 3203 비슷한 설정 예시: 가로 70, 세로 25, 열 3, 행 10, 여백 10/10, 간격 3/3 (프린터마다 약간 조정)")
101
+
102
+ # -----------------
103
+ # 텍스트 생성 함수
104
+ # -----------------
105
+ def year_range(series):
106
  s = series.astype(str).fillna("")
107
+ v = s[~s.isin(["", "0", "0000"])]
108
+ if len(v) == 0:
109
  return "0000-0000"
110
+ nums = pd.to_numeric(v, errors="coerce").dropna().astype(int)
111
+ if len(nums) == 0:
112
  return "0000-0000"
113
+ return f"{nums.min():04d}-{nums.max():04d}"
114
 
115
+ def build_records(df: pd.DataFrame):
116
  df = df.copy()
117
  df["박스번호"] = df["박스번호"].astype(str).str.zfill(4)
118
+ # 생산연도(범위)
 
 
 
119
  if "종료연도" in df.columns:
120
+ yr = df.groupby("박스번호")["종료연도"].apply(year_range).reset_index()
121
+ yr.columns = ["박스번호", "생산연도"]
122
  else:
123
+ yr = pd.DataFrame({"박스번호": df["박스번호"].unique(), "생산연도": "0000-0000"})
124
+ # 목록
 
125
  has_mgmt = "관리번호" in df.columns
126
  list_rows = []
127
  for box, g in df.groupby("박스번호"):
128
+ lines = [f"- {r['관리번호']} {r.get('제목','')}" if has_mgmt else f"- {r.get('제목','')}"
129
  for _, r in g.iterrows()]
130
+ list_rows.append({"박스번호": box, "목록": "\n".join(lines)})
131
  list_df = pd.DataFrame(list_rows)
132
+ # 대표 메타
133
+ cols = ["박스번호","보존기간","단위업무","기록물철","제목"]
134
+ meta_exist = [c for c in cols if c in df.columns]
135
+ meta = df.groupby("박스번호", as_index=False).first()[meta_exist] if meta_exist else pd.DataFrame({"박스번호": df["박스번호"].unique()})
136
+ merged = meta.merge(list_df, on="박스번호", how="left").merge(yr, on="박스번호", how="left")
137
+ return merged.sort_values("박스번호").to_dict(orient="records")
138
+
139
+ def draw_label(c: canvas.Canvas, x, y, w, h, rec, font_name, fs_big, fs_mid, fs_small, line_gap):
140
+ """
141
+ 좌표계: reportlab은 좌하단이 원점.
142
+ x,y = 라벨 좌하단. w,h = 라벨 크기.
143
+ """
144
+ # 여백
145
+ inner_x = x + pad_x * mm
146
+ inner_y = y + pad_y * mm
147
+ inner_w = w - 2 * pad_x * mm
148
+ inner_h = h - 2 * pad_y * mm
149
+
150
+ # 상단 굵은 줄: 박스번호
151
+ c.setFont(font_name, fs_big)
152
+ boxno = rec.get("박스번호", "")
153
+ c.drawString(inner_x, inner_y + inner_h - fs_big*1.1, f"{boxno}")
154
+
155
+ # 2행: (생산연도/보존기간)
156
+ c.setFont(font_name, fs_mid)
157
+ prod = rec.get("생산연도","")
158
+ keep = rec.get("보존기간","") or ""
159
+ line_y = inner_y + inner_h - fs_big*1.1 - fs_mid*1.5
160
+ c.drawString(inner_x, line_y, f"{prod} {keep}")
161
+
162
+ # 3행: 단위업무 / 기록물철 (있으면)
163
+ line_y -= fs_mid * 1.2
164
+ unit = rec.get("단위업무","") or ""
165
+ series = rec.get("기록물철","") or ""
166
+ if unit or series:
167
+ c.setFont(font_name, fs_mid)
168
+ c.drawString(inner_x, line_y, f"{unit} {series}")
169
+ line_y -= fs_mid * 1.0
170
+
171
+ # 목록(여러 줄, 작은 글씨)
172
+ c.setFont(font_name, fs_small)
173
+ list_text = rec.get("목록","") or ""
174
+ for ln in list_text.split("\n"):
175
+ if line_y < inner_y + fs_small * 1.2: # 라벨 하단 넘어가면 중단
176
+ break
177
+ c.drawString(inner_x, line_y, ln)
178
+ line_y -= fs_small * line_gap
179
+
180
+ def make_pdf(records):
181
+ buffer = BytesIO()
182
+ if page_size == "A4":
183
+ pw, ph = A4
184
+ else:
185
+ pw, ph = A4
186
+
187
+ c = canvas.Canvas(buffer, pagesize=(pw, ph))
188
+ c.setAuthor("BoxLabel")
189
+ c.setTitle("Box Labels")
190
+
191
+ pdfmetrics.getFont(font_name) # ensure registered
192
+
193
+ # 좌표/크기(mm → pt)
194
+ L = margin_left * mm
195
+ T = margin_top * mm
196
+ W = label_w * mm
197
+ H = label_h * mm
198
+ GX = gap_x * mm
199
+ GY = gap_y * mm
200
+
201
+ per_page = int(rows * cols)
202
+ total_pages = math.ceil(len(records) / per_page) if records else 1
203
+
204
+ idx = 0
205
+ for p in range(total_pages):
206
+ for r in range(int(rows)):
207
+ for ccol in range(int(cols)):
208
+ if idx >= len(records):
209
+ break
210
+ # 좌표 계산 (좌하단 원점이므로 상단에서 내려오게 Y를 조정)
211
+ x = L + ccol * (W + GX)
212
+ y_top = ph - T - r * (H + GY)
213
+ y = y_top - H
214
+ draw_label(c, x, y, W, H, records[idx], font_name, fs_big, fs_mid, fs_small, line_gap)
215
+ idx += 1
216
+ if idx >= len(records):
217
+ break
218
+ c.showPage()
219
+ c.save()
220
+ buffer.seek(0)
221
+ return buffer
222
+
223
+ # -----------------
224
+ # 메인 동작
225
+ # -----------------
226
+ if df is not None:
227
+ # 미리보기
228
+ st.subheader("📋 데이터 미리보기")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  st.dataframe(df.head(10), use_container_width=True)
230
 
231
+ records = build_records(df)
232
+ st.write(f"총 **{len(records)}**개 박스가 감지되었습니다.")
233
+ default_sel = [r["박스번호"] for r in records]
234
+ sel = st.multiselect("생성할 박스번호 선택 (비우면 전체)", options=default_sel)
235
+ if sel:
236
+ records = [r for r in records if r["박스번호"] in set(sel)]
237
+
238
+ if st.button("🚀 PDF 생성"):
239
+ pdf = make_pdf(records)
240
+ st.download_button("⬇️ PDF 다운로드", data=pdf.getvalue(), file_name="box_labels.pdf", mime="application/pdf")