Simplify hwpx: plain UTF-8 text with .hwpx extension
Browse files
app.py
CHANGED
|
@@ -306,99 +306,21 @@ def generate(req: GenerateRequest):
|
|
| 306 |
|
| 307 |
@app.post("/download/hwpx")
|
| 308 |
def download_hwpx(req: HwpxRequest):
|
| 309 |
-
"""텍스트를
|
| 310 |
-
import io
|
| 311 |
import re
|
| 312 |
-
import zipfile
|
| 313 |
from urllib.parse import quote
|
| 314 |
-
from xml.sax.saxutils import escape as xe
|
| 315 |
from fastapi.responses import Response
|
| 316 |
|
| 317 |
-
lines = req.text.split("\n")
|
| 318 |
-
|
| 319 |
-
CONTAINER = (
|
| 320 |
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
| 321 |
-
'<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
|
| 322 |
-
' <rootfiles>\n'
|
| 323 |
-
' <rootfile full-path="Contents/content.hpf" media-type="application/hwp+zip"/>\n'
|
| 324 |
-
' </rootfiles>\n'
|
| 325 |
-
'</container>'
|
| 326 |
-
)
|
| 327 |
-
HPF = (
|
| 328 |
-
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
| 329 |
-
'<opf:package xmlns:opf="http://www.idpf.org/2007/opf" version="2.0">\n'
|
| 330 |
-
' <opf:metadata>'
|
| 331 |
-
'<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/"/>'
|
| 332 |
-
'<dc:language xmlns:dc="http://purl.org/dc/elements/1.1/">ko</dc:language>'
|
| 333 |
-
'</opf:metadata>\n'
|
| 334 |
-
' <opf:manifest>\n'
|
| 335 |
-
' <opf:item id="header" href="header.xml" media-type="application/xml"/>\n'
|
| 336 |
-
' <opf:item id="section0" href="section0.xml" media-type="application/xml"/>\n'
|
| 337 |
-
' </opf:manifest>\n'
|
| 338 |
-
' <opf:spine><opf:itemref idref="section0"/></opf:spine>\n'
|
| 339 |
-
'</opf:package>'
|
| 340 |
-
)
|
| 341 |
-
HEADER = (
|
| 342 |
-
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
| 343 |
-
'<hh:Head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head"\n'
|
| 344 |
-
' xmlns:hf="http://www.hancom.co.kr/hwpml/2011/charshape"\n'
|
| 345 |
-
' xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">\n'
|
| 346 |
-
' <hh:RefList>\n'
|
| 347 |
-
' <hh:CharShapes>\n'
|
| 348 |
-
' <hf:CharShape Id="0" Height="1000" TextColor="0" BoldWeight="400"\n'
|
| 349 |
-
' FaceNameHangul="맑은 고딕" FaceNameLatin="맑은 고딕"\n'
|
| 350 |
-
' FaceNameHanja="맑은 고딕" Ratio="100" Spacing="0"/>\n'
|
| 351 |
-
' </hh:CharShapes>\n'
|
| 352 |
-
' <hh:ParaShapes>\n'
|
| 353 |
-
' <hp:ParaShape Id="0" AlignType="Justify"\n'
|
| 354 |
-
' LineSpacing="160" LineSpacingType="PercentsOfLine"\n'
|
| 355 |
-
' SpaceBefore="0" SpaceAfter="85"/>\n'
|
| 356 |
-
' </hh:ParaShapes>\n'
|
| 357 |
-
' <hh:Styles>\n'
|
| 358 |
-
' <hh:Style Id="0" EngName="Normal" HangulName="바탕글"\n'
|
| 359 |
-
' CharPrIDRef="0" ParaPrIDRef="0"/>\n'
|
| 360 |
-
' </hh:Styles>\n'
|
| 361 |
-
' </hh:RefList>\n'
|
| 362 |
-
'</hh:Head>'
|
| 363 |
-
)
|
| 364 |
-
|
| 365 |
-
def _para(pid, line):
|
| 366 |
-
txt = xe(line)
|
| 367 |
-
run = (f'<hp:Run><hp:RPr CharPrIDRef="0"/><hp:T>{txt}</hp:T></hp:Run>'
|
| 368 |
-
if txt else "")
|
| 369 |
-
return (f' <hp:P Id="{pid}">'
|
| 370 |
-
f'<hp:PPr ParaPrIDRef="0"/>'
|
| 371 |
-
f'{run}'
|
| 372 |
-
f'</hp:P>')
|
| 373 |
-
|
| 374 |
-
body = "\n".join(_para(i, l) for i, l in enumerate(lines))
|
| 375 |
-
SECTION = (
|
| 376 |
-
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
| 377 |
-
'<hh:SEC xmlns:hh="http://www.hancom.co.kr/hwpml/2011/section"\n'
|
| 378 |
-
' xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">\n'
|
| 379 |
-
f'{body}\n'
|
| 380 |
-
'</hh:SEC>'
|
| 381 |
-
)
|
| 382 |
-
|
| 383 |
-
buf = io.BytesIO()
|
| 384 |
-
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 385 |
-
mi = zipfile.ZipInfo("mimetype")
|
| 386 |
-
mi.compress_type = zipfile.ZIP_STORED
|
| 387 |
-
zf.writestr(mi, "application/hwp+zip")
|
| 388 |
-
zf.writestr("META-INF/container.xml", CONTAINER.encode("utf-8"))
|
| 389 |
-
zf.writestr("Contents/content.hpf", HPF.encode("utf-8"))
|
| 390 |
-
zf.writestr("Contents/header.xml", HEADER.encode("utf-8"))
|
| 391 |
-
zf.writestr("Contents/section0.xml", SECTION.encode("utf-8"))
|
| 392 |
-
|
| 393 |
-
# 파일명: 출력에서 제목 추출, 없으면 "문서"
|
| 394 |
m = re.search(r"^제\s*목\s+(.+)$", req.text, re.MULTILINE)
|
| 395 |
title = m.group(1).strip() if m else "문서"
|
| 396 |
safe = re.sub(r'[\\/:*?"<>|]', "_", title)[:50]
|
| 397 |
encoded = quote(safe, safe="")
|
| 398 |
|
|
|
|
|
|
|
| 399 |
return Response(
|
| 400 |
-
content=
|
| 401 |
-
media_type="application/
|
| 402 |
headers={
|
| 403 |
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}.hwpx",
|
| 404 |
},
|
|
|
|
| 306 |
|
| 307 |
@app.post("/download/hwpx")
|
| 308 |
def download_hwpx(req: HwpxRequest):
|
| 309 |
+
"""텍스트를 .hwpx 파일로 다운로드 (UTF-8 텍스트, 확장자 .hwpx)."""
|
|
|
|
| 310 |
import re
|
|
|
|
| 311 |
from urllib.parse import quote
|
|
|
|
| 312 |
from fastapi.responses import Response
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
m = re.search(r"^제\s*목\s+(.+)$", req.text, re.MULTILINE)
|
| 315 |
title = m.group(1).strip() if m else "문서"
|
| 316 |
safe = re.sub(r'[\\/:*?"<>|]', "_", title)[:50]
|
| 317 |
encoded = quote(safe, safe="")
|
| 318 |
|
| 319 |
+
content = req.text.encode("utf-8-sig") # UTF-8 with BOM
|
| 320 |
+
|
| 321 |
return Response(
|
| 322 |
+
content=content,
|
| 323 |
+
media_type="application/octet-stream",
|
| 324 |
headers={
|
| 325 |
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}.hwpx",
|
| 326 |
},
|