python-hwpx 2.6__py3-none-any.whl → 2.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Sequence
11
+ from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
12
+
13
+ from lxml import etree
14
+
15
+ from .package_validator import validate_package
16
+
17
+ _XML_SUFFIXES = (".xml", ".hpf")
18
+ _PACK_METADATA_NAME = ".hwpx-pack-metadata.json"
19
+
20
+ __all__ = [
21
+ "ArchiveEntryInfo",
22
+ "UnpackResult",
23
+ "PackResult",
24
+ "pack_hwpx",
25
+ "unpack_hwpx",
26
+ "pack_main",
27
+ "unpack_main",
28
+ "main",
29
+ ]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ArchiveEntryInfo:
34
+ path: str
35
+ compress_type: int
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class UnpackResult:
40
+ output_dir: Path
41
+ metadata_path: Path
42
+ entries: tuple[ArchiveEntryInfo, ...]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PackResult:
47
+ output_path: Path
48
+ entries: tuple[str, ...]
49
+
50
+
51
+ def _guard_destructive_target(path: Path) -> None:
52
+ resolved = path.resolve()
53
+ if resolved == Path(resolved.anchor):
54
+ raise ValueError(f"refusing to overwrite filesystem root: {resolved}")
55
+ if resolved == Path.cwd().resolve():
56
+ raise ValueError(f"refusing to overwrite current working directory: {resolved}")
57
+
58
+
59
+ def _prepare_output_dir(output_dir: Path, *, overwrite: bool) -> None:
60
+ if output_dir.exists() and not output_dir.is_dir():
61
+ raise NotADirectoryError(f"output exists and is not a directory: {output_dir}")
62
+ if output_dir.exists():
63
+ if any(output_dir.iterdir()):
64
+ if not overwrite:
65
+ raise FileExistsError(f"output directory is not empty: {output_dir}")
66
+ _guard_destructive_target(output_dir)
67
+ shutil.rmtree(output_dir)
68
+ else:
69
+ output_dir.rmdir()
70
+ output_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+
73
+ def _prepare_output_path(output_path: Path, *, overwrite: bool) -> None:
74
+ output_path.parent.mkdir(parents=True, exist_ok=True)
75
+ if output_path.exists() and not overwrite:
76
+ raise FileExistsError(f"output file already exists: {output_path}")
77
+
78
+
79
+ def _format_xml_bytes(payload: bytes) -> bytes:
80
+ try:
81
+ element = etree.fromstring(payload)
82
+ except etree.XMLSyntaxError:
83
+ return payload
84
+ etree.indent(element, space=" ")
85
+ return etree.tostring(
86
+ element,
87
+ pretty_print=True,
88
+ xml_declaration=True,
89
+ encoding="UTF-8",
90
+ )
91
+
92
+
93
+ def _iter_file_entries(archive: ZipFile) -> tuple[ArchiveEntryInfo, ...]:
94
+ entries: list[ArchiveEntryInfo] = []
95
+ for info in archive.infolist():
96
+ if info.is_dir():
97
+ continue
98
+ entries.append(ArchiveEntryInfo(path=info.filename, compress_type=info.compress_type))
99
+ return tuple(entries)
100
+
101
+
102
+ def _metadata_path(root: Path) -> Path:
103
+ return root / _PACK_METADATA_NAME
104
+
105
+
106
+ def _write_pack_metadata(root: Path, entries: tuple[ArchiveEntryInfo, ...]) -> Path:
107
+ destination = _metadata_path(root)
108
+ payload = {
109
+ "format_version": 1,
110
+ "entries": [asdict(entry) for entry in entries],
111
+ }
112
+ destination.write_text(json.dumps(payload, indent=2), encoding="utf-8")
113
+ return destination
114
+
115
+
116
+ def _read_pack_metadata(root: Path) -> tuple[ArchiveEntryInfo, ...]:
117
+ metadata_file = _metadata_path(root)
118
+ if not metadata_file.is_file():
119
+ return ()
120
+
121
+ data = json.loads(metadata_file.read_text(encoding="utf-8"))
122
+ entries: list[ArchiveEntryInfo] = []
123
+ for entry in data.get("entries", []):
124
+ path = str(entry.get("path", "")).strip()
125
+ if not path:
126
+ continue
127
+ entries.append(
128
+ ArchiveEntryInfo(
129
+ path=path.replace("\\", "/"),
130
+ compress_type=int(entry.get("compress_type", ZIP_DEFLATED)),
131
+ )
132
+ )
133
+ return tuple(entries)
134
+
135
+
136
+ def _discover_files(root: Path) -> set[str]:
137
+ paths: set[str] = set()
138
+ for path in root.rglob("*"):
139
+ if not path.is_file():
140
+ continue
141
+ rel_path = path.relative_to(root).as_posix()
142
+ if rel_path == _PACK_METADATA_NAME:
143
+ continue
144
+ paths.add(rel_path)
145
+ return paths
146
+
147
+
148
+ def _resolve_write_order(paths: set[str], metadata: tuple[ArchiveEntryInfo, ...]) -> tuple[str, ...]:
149
+ ordered: list[str] = []
150
+ seen: set[str] = set()
151
+
152
+ if "mimetype" in paths:
153
+ ordered.append("mimetype")
154
+ seen.add("mimetype")
155
+
156
+ for entry in metadata:
157
+ if entry.path in paths and entry.path not in seen:
158
+ ordered.append(entry.path)
159
+ seen.add(entry.path)
160
+
161
+ for path in sorted(paths):
162
+ if path in seen:
163
+ continue
164
+ ordered.append(path)
165
+ seen.add(path)
166
+
167
+ return tuple(ordered)
168
+
169
+
170
+ def _summarize_pack_validation(output_path: Path) -> None:
171
+ report = validate_package(output_path)
172
+ if report.ok:
173
+ return
174
+ summary = "\n".join(f"- {issue}" for issue in report.issues[:10])
175
+ raise ValueError(f"packed archive failed validation:\n{summary}")
176
+
177
+
178
+ def unpack_hwpx(
179
+ source: str | Path,
180
+ output_dir: str | Path,
181
+ *,
182
+ overwrite: bool = False,
183
+ pretty_xml: bool = True,
184
+ ) -> UnpackResult:
185
+ source_path = Path(source)
186
+ if not source_path.is_file():
187
+ raise FileNotFoundError(f"input file not found: {source_path}")
188
+
189
+ destination = Path(output_dir)
190
+ _prepare_output_dir(destination, overwrite=overwrite)
191
+
192
+ with ZipFile(source_path, "r") as archive:
193
+ entries = _iter_file_entries(archive)
194
+ for entry in entries:
195
+ data = archive.read(entry.path)
196
+ if pretty_xml and entry.path.endswith(_XML_SUFFIXES):
197
+ data = _format_xml_bytes(data)
198
+ target = destination / entry.path
199
+ target.parent.mkdir(parents=True, exist_ok=True)
200
+ target.write_bytes(data)
201
+
202
+ metadata_path = _write_pack_metadata(destination, entries)
203
+ return UnpackResult(output_dir=destination, metadata_path=metadata_path, entries=entries)
204
+
205
+
206
+ def pack_hwpx(
207
+ input_dir: str | Path,
208
+ output_path: str | Path,
209
+ *,
210
+ overwrite: bool = False,
211
+ ) -> PackResult:
212
+ root = Path(input_dir)
213
+ if not root.is_dir():
214
+ raise FileNotFoundError(f"input directory not found: {root}")
215
+
216
+ destination = Path(output_path)
217
+ _prepare_output_path(destination, overwrite=overwrite)
218
+
219
+ files = _discover_files(root)
220
+ if "mimetype" not in files:
221
+ raise FileNotFoundError(f"missing required 'mimetype' file in {root}")
222
+
223
+ metadata = _read_pack_metadata(root)
224
+ compress_types = {entry.path: entry.compress_type for entry in metadata}
225
+ ordered_paths = _resolve_write_order(files, metadata)
226
+
227
+ fd, tmp_name = tempfile.mkstemp(dir=str(destination.parent), suffix=".hwpx.tmp")
228
+ os.close(fd)
229
+ tmp_path = Path(tmp_name)
230
+ try:
231
+ with ZipFile(tmp_path, "w", ZIP_DEFLATED) as archive:
232
+ archive.write(root / "mimetype", "mimetype", compress_type=ZIP_STORED)
233
+ for rel_path in ordered_paths:
234
+ if rel_path == "mimetype":
235
+ continue
236
+ compress_type = compress_types.get(rel_path, ZIP_DEFLATED)
237
+ if compress_type != ZIP_STORED:
238
+ compress_type = ZIP_DEFLATED
239
+ archive.write(root / rel_path, rel_path, compress_type=compress_type)
240
+
241
+ _summarize_pack_validation(tmp_path)
242
+ os.replace(tmp_path, destination)
243
+ except BaseException:
244
+ try:
245
+ tmp_path.unlink(missing_ok=True)
246
+ except OSError:
247
+ pass
248
+ raise
249
+
250
+ return PackResult(output_path=destination, entries=ordered_paths)
251
+
252
+
253
+ def unpack_main(argv: Sequence[str] | None = None) -> int:
254
+ parser = argparse.ArgumentParser(description="Unpack an HWPX file into a directory")
255
+ parser.add_argument("input", help="Input .hwpx path")
256
+ parser.add_argument("output", help="Output directory")
257
+ parser.add_argument(
258
+ "--force",
259
+ action="store_true",
260
+ help="Allow deleting an existing non-empty output directory",
261
+ )
262
+ parser.add_argument(
263
+ "--no-pretty-xml",
264
+ action="store_true",
265
+ help="Keep XML payloads in their original byte formatting",
266
+ )
267
+ args = parser.parse_args(argv)
268
+
269
+ try:
270
+ result = unpack_hwpx(
271
+ args.input,
272
+ args.output,
273
+ overwrite=args.force,
274
+ pretty_xml=not args.no_pretty_xml,
275
+ )
276
+ except Exception as exc:
277
+ print(f"ERROR: {exc}")
278
+ return 1
279
+
280
+ print(f"Unpacked {args.input} -> {result.output_dir}")
281
+ print(f"Recorded archive metadata at {result.metadata_path}")
282
+ return 0
283
+
284
+
285
+ def pack_main(argv: Sequence[str] | None = None) -> int:
286
+ parser = argparse.ArgumentParser(description="Pack a directory into an HWPX archive")
287
+ parser.add_argument("input", help="Input directory")
288
+ parser.add_argument("output", help="Output .hwpx path")
289
+ parser.add_argument(
290
+ "--force",
291
+ action="store_true",
292
+ help="Allow replacing an existing output file",
293
+ )
294
+ args = parser.parse_args(argv)
295
+
296
+ try:
297
+ result = pack_hwpx(args.input, args.output, overwrite=args.force)
298
+ except Exception as exc:
299
+ print(f"ERROR: {exc}")
300
+ return 1
301
+
302
+ print(f"Packed {args.input} -> {result.output_path}")
303
+ return 0
304
+
305
+
306
+ def main(argv: Sequence[str] | None = None) -> int:
307
+ parser = argparse.ArgumentParser(description="HWPX archive utility helpers")
308
+ subparsers = parser.add_subparsers(dest="command", required=True)
309
+
310
+ unpack_parser = subparsers.add_parser("unpack", help="Unpack an HWPX file")
311
+ unpack_parser.add_argument("input")
312
+ unpack_parser.add_argument("output")
313
+ unpack_parser.add_argument("--force", action="store_true")
314
+ unpack_parser.add_argument("--no-pretty-xml", action="store_true")
315
+
316
+ pack_parser = subparsers.add_parser("pack", help="Pack a directory into HWPX")
317
+ pack_parser.add_argument("input")
318
+ pack_parser.add_argument("output")
319
+ pack_parser.add_argument("--force", action="store_true")
320
+
321
+ args = parser.parse_args(argv)
322
+ if args.command == "unpack":
323
+ forward = [args.input, args.output]
324
+ if args.force:
325
+ forward.append("--force")
326
+ if args.no_pretty_xml:
327
+ forward.append("--no-pretty-xml")
328
+ return unpack_main(forward)
329
+
330
+ forward = [args.input, args.output]
331
+ if args.force:
332
+ forward.append("--force")
333
+ return pack_main(forward)
334
+
335
+
336
+ if __name__ == "__main__": # pragma: no cover - CLI convenience
337
+ raise SystemExit(main())
@@ -68,13 +68,15 @@ def _parse_xml(payload: bytes) -> ET.Element:
68
68
  def _container_rootfiles(container_root: ET.Element) -> list[str]:
69
69
  paths: list[str] = []
70
70
  for namespace in CONTAINER_NS.values():
71
- paths.extend(
72
- elem.get("full-path")
73
- or elem.get("fullPath")
74
- or elem.get("full_path")
75
- for elem in container_root.findall(f".//{{{namespace}}}rootfile")
76
- )
77
- return [path for path in paths if path]
71
+ for elem in container_root.findall(f".//{{{namespace}}}rootfile"):
72
+ path = (
73
+ elem.get("full-path")
74
+ or elem.get("fullPath")
75
+ or elem.get("full_path")
76
+ )
77
+ if path:
78
+ paths.append(path)
79
+ return paths
78
80
 
79
81
 
80
82
  def _manifest_hrefs(manifest_root: ET.Element) -> set[str]:
hwpx/tools/page_guard.py CHANGED
@@ -1,3 +1,9 @@
1
+ """Proxy checks for layout drift between a reference and an output HWPX.
2
+
3
+ This module does not calculate rendered page counts. It compares structural and
4
+ textual metrics that often correlate with page-layout drift.
5
+ """
6
+
1
7
  from __future__ import annotations
2
8
 
3
9
  import argparse
@@ -16,6 +22,22 @@ NS = {
16
22
  "opf": "http://www.idpf.org/2007/opf/",
17
23
  }
18
24
 
25
+ _SHAPE_TAGS = {
26
+ "line",
27
+ "rect",
28
+ "ellipse",
29
+ "arc",
30
+ "polygon",
31
+ "curve",
32
+ "connectLine",
33
+ "textart",
34
+ "pic",
35
+ "compose",
36
+ "equation",
37
+ "ole",
38
+ "container",
39
+ }
40
+
19
41
  __all__ = [
20
42
  "DocumentMetrics",
21
43
  "collect_metrics",
@@ -31,7 +53,11 @@ class DocumentMetrics:
31
53
  page_break_count: int
32
54
  column_break_count: int
33
55
  table_count: int
56
+ shape_count: int
57
+ control_count: int
34
58
  table_shapes: list[tuple[str, str, str, str, str, str]]
59
+ shape_types: list[tuple[str, int]]
60
+ control_types: list[tuple[str, int]]
35
61
  text_char_total: int
36
62
  text_char_total_nospace: int
37
63
  paragraph_text_lengths: list[int]
@@ -66,6 +92,12 @@ def _text_of_t_node(node: etree._Element) -> str:
66
92
  return "".join(node.itertext())
67
93
 
68
94
 
95
+ def _local_name(tag: str) -> str:
96
+ if "}" in tag:
97
+ return tag.split("}", 1)[1]
98
+ return tag
99
+
100
+
69
101
  def _iter_section_roots(source: str | Path | bytes | BinaryIO) -> Iterable[etree._Element]:
70
102
  if isinstance(source, bytes):
71
103
  archive = ZipFile(io.BytesIO(source), "r")
@@ -85,6 +117,8 @@ def collect_metrics(source: str | Path | bytes | BinaryIO) -> DocumentMetrics:
85
117
  paragraphs: list[etree._Element] = []
86
118
  tables: list[etree._Element] = []
87
119
  table_shapes: list[tuple[str, str, str, str, str, str]] = []
120
+ shape_types: dict[str, int] = {}
121
+ control_types: dict[str, int] = {}
88
122
  paragraph_text_lengths: list[int] = []
89
123
  text_char_total = 0
90
124
  text_char_total_nospace = 0
@@ -100,6 +134,19 @@ def collect_metrics(source: str | Path | bytes | BinaryIO) -> DocumentMetrics:
100
134
  section_tables = root.xpath(".//hp:tbl", namespaces=NS)
101
135
  tables.extend(section_tables)
102
136
 
137
+ for element in root.iter():
138
+ name = _local_name(element.tag)
139
+ if name in _SHAPE_TAGS:
140
+ shape_types[name] = shape_types.get(name, 0) + 1
141
+ if name == "ctrl":
142
+ control_counted = False
143
+ for child in element:
144
+ child_name = _local_name(child.tag)
145
+ control_types[child_name] = control_types.get(child_name, 0) + 1
146
+ control_counted = True
147
+ if not control_counted:
148
+ control_types["ctrl"] = control_types.get("ctrl", 0) + 1
149
+
103
150
  for table in section_tables:
104
151
  size = table.find("hp:sz", namespaces=NS)
105
152
  table_shapes.append(
@@ -132,7 +179,11 @@ def collect_metrics(source: str | Path | bytes | BinaryIO) -> DocumentMetrics:
132
179
  page_break_count=page_break_count,
133
180
  column_break_count=column_break_count,
134
181
  table_count=len(tables),
182
+ shape_count=sum(shape_types.values()),
183
+ control_count=sum(control_types.values()),
135
184
  table_shapes=table_shapes,
185
+ shape_types=sorted(shape_types.items()),
186
+ control_types=sorted(control_types.items()),
136
187
  text_char_total=text_char_total,
137
188
  text_char_total_nospace=text_char_total_nospace,
138
189
  paragraph_text_lengths=paragraph_text_lengths,
@@ -173,8 +224,18 @@ def compare_metrics(
173
224
  )
174
225
  if reference.table_count != output.table_count:
175
226
  errors.append(f"table count mismatch: ref={reference.table_count}, out={output.table_count}")
227
+ if reference.shape_count != output.shape_count:
228
+ errors.append(f"shape count mismatch: ref={reference.shape_count}, out={output.shape_count}")
229
+ if reference.control_count != output.control_count:
230
+ errors.append(
231
+ f"control count mismatch: ref={reference.control_count}, out={output.control_count}"
232
+ )
176
233
  if reference.table_shapes != output.table_shapes:
177
234
  errors.append("table shape mismatch (rowCnt/colCnt/width/height/repeatHeader/pageBreak)")
235
+ if reference.shape_types != output.shape_types:
236
+ errors.append("shape type histogram mismatch")
237
+ if reference.control_types != output.control_types:
238
+ errors.append("control type histogram mismatch")
178
239
 
179
240
  text_delta = _ratio_delta(reference.text_char_total_nospace, output.text_char_total_nospace)
180
241
  if text_delta > max_text_delta_ratio:
@@ -202,7 +263,9 @@ def compare_metrics(
202
263
 
203
264
 
204
265
  def main(argv: Sequence[str] | None = None) -> int:
205
- parser = argparse.ArgumentParser(description="Reference-vs-output HWPX page drift guard")
266
+ parser = argparse.ArgumentParser(
267
+ description="Reference-vs-output HWPX layout-drift proxy checker"
268
+ )
206
269
  parser.add_argument("--reference", "-r", required=True, help="Reference HWPX path")
207
270
  parser.add_argument("--output", "-o", required=True, help="Output HWPX path")
208
271
  parser.add_argument("--max-text-delta-ratio", type=float, default=0.15)
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import Sequence
8
+ from xml.etree import ElementTree as ET
9
+
10
+ from ..opc.package import HwpxPackage
11
+ from .page_guard import DocumentMetrics, collect_metrics
12
+
13
+ _HH_NS = "http://www.hancom.co.kr/hwpml/2011/head"
14
+ _HH = {"hh": _HH_NS}
15
+
16
+ __all__ = [
17
+ "HeaderSummary",
18
+ "TemplateAnalysis",
19
+ "analyze_template",
20
+ "extract_template_parts",
21
+ "main",
22
+ ]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class HeaderSummary:
27
+ font_count: int
28
+ char_pr_count: int
29
+ para_pr_count: int
30
+ border_fill_count: int
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TemplateAnalysis:
35
+ source_name: str
36
+ part_names: tuple[str, ...]
37
+ rootfiles: tuple[str, ...]
38
+ manifest_path: str
39
+ header_paths: tuple[str, ...]
40
+ section_paths: tuple[str, ...]
41
+ version_path: str | None
42
+ header_summary: HeaderSummary
43
+ proxy_metrics: DocumentMetrics
44
+
45
+
46
+ def _summarize_header(element: ET.Element | None) -> HeaderSummary:
47
+ if element is None:
48
+ return HeaderSummary(font_count=0, char_pr_count=0, para_pr_count=0, border_fill_count=0)
49
+
50
+ font_count = len(element.findall(".//hh:fontface/hh:font", _HH))
51
+ char_pr_count = len(element.findall(".//hh:charPr", _HH))
52
+ para_pr_count = len(element.findall(".//hh:paraPr", _HH))
53
+ border_fill_count = len(element.findall(".//hh:borderFill", _HH))
54
+ return HeaderSummary(
55
+ font_count=font_count,
56
+ char_pr_count=char_pr_count,
57
+ para_pr_count=para_pr_count,
58
+ border_fill_count=border_fill_count,
59
+ )
60
+
61
+
62
+ def analyze_template(source: str | Path) -> TemplateAnalysis:
63
+ source_path = Path(source)
64
+ package = HwpxPackage.open(source_path)
65
+
66
+ header_paths = tuple(package.header_paths())
67
+ header_xml = package.get_xml(header_paths[0]) if header_paths else None
68
+ manifest_path = package.main_content.full_path
69
+ version_path = package.version_path()
70
+
71
+ return TemplateAnalysis(
72
+ source_name=source_path.name,
73
+ part_names=tuple(package.part_names()),
74
+ rootfiles=tuple(rootfile.full_path for rootfile in package.iter_rootfiles()),
75
+ manifest_path=manifest_path,
76
+ header_paths=header_paths,
77
+ section_paths=tuple(package.section_paths()),
78
+ version_path=version_path,
79
+ header_summary=_summarize_header(header_xml),
80
+ proxy_metrics=collect_metrics(source_path),
81
+ )
82
+
83
+
84
+ def _write_part(package: HwpxPackage, part_name: str, destination: Path) -> Path:
85
+ destination.parent.mkdir(parents=True, exist_ok=True)
86
+ destination.write_bytes(package.get_part(part_name))
87
+ return destination
88
+
89
+
90
+ def extract_template_parts(
91
+ source: str | Path,
92
+ *,
93
+ extract_dir: str | Path | None = None,
94
+ extract_header: str | Path | None = None,
95
+ extract_section: str | Path | None = None,
96
+ extract_section_dir: str | Path | None = None,
97
+ ) -> tuple[Path, ...]:
98
+ source_path = Path(source)
99
+ package = HwpxPackage.open(source_path)
100
+ written: list[Path] = []
101
+
102
+ if extract_dir is not None:
103
+ root = Path(extract_dir)
104
+ root.mkdir(parents=True, exist_ok=True)
105
+ written.append(_write_part(package, package.main_content.full_path, root / package.main_content.full_path))
106
+ for part_name in package.header_paths():
107
+ written.append(_write_part(package, part_name, root / part_name))
108
+ for part_name in package.section_paths():
109
+ written.append(_write_part(package, part_name, root / part_name))
110
+ version_path = package.version_path()
111
+ if version_path and package.has_part(version_path):
112
+ written.append(_write_part(package, version_path, root / version_path))
113
+ if package.has_part(package.CONTAINER_PATH):
114
+ written.append(_write_part(package, package.CONTAINER_PATH, root / package.CONTAINER_PATH))
115
+
116
+ if extract_header is not None:
117
+ header_paths = package.header_paths()
118
+ if not header_paths:
119
+ raise FileNotFoundError("package does not contain a header part")
120
+ written.append(_write_part(package, header_paths[0], Path(extract_header)))
121
+
122
+ if extract_section is not None:
123
+ section_paths = package.section_paths()
124
+ if not section_paths:
125
+ raise FileNotFoundError("package does not contain a section part")
126
+ written.append(_write_part(package, section_paths[0], Path(extract_section)))
127
+
128
+ if extract_section_dir is not None:
129
+ section_root = Path(extract_section_dir)
130
+ section_root.mkdir(parents=True, exist_ok=True)
131
+ for part_name in package.section_paths():
132
+ written.append(_write_part(package, part_name, section_root / Path(part_name).name))
133
+
134
+ return tuple(written)
135
+
136
+
137
+ def _print_summary(analysis: TemplateAnalysis) -> None:
138
+ metrics = analysis.proxy_metrics
139
+ print(f"source: {analysis.source_name}")
140
+ print(f"manifest: {analysis.manifest_path}")
141
+ print(f"rootfiles: {', '.join(analysis.rootfiles) or '(none)'}")
142
+ print(f"headers: {', '.join(analysis.header_paths) or '(none)'}")
143
+ print(f"sections: {', '.join(analysis.section_paths) or '(none)'}")
144
+ if analysis.version_path:
145
+ print(f"version part: {analysis.version_path}")
146
+ print(
147
+ "header styles: "
148
+ f"fonts={analysis.header_summary.font_count}, "
149
+ f"charPr={analysis.header_summary.char_pr_count}, "
150
+ f"paraPr={analysis.header_summary.para_pr_count}, "
151
+ f"borderFill={analysis.header_summary.border_fill_count}"
152
+ )
153
+ print(
154
+ "layout-drift proxy: "
155
+ f"paragraphs={metrics.paragraph_count}, "
156
+ f"tables={metrics.table_count}, "
157
+ f"shapes={metrics.shape_count}, "
158
+ f"controls={metrics.control_count}, "
159
+ f"pageBreaks={metrics.page_break_count}, "
160
+ f"columnBreaks={metrics.column_break_count}"
161
+ )
162
+
163
+
164
+ def main(argv: Sequence[str] | None = None) -> int:
165
+ parser = argparse.ArgumentParser(
166
+ description="Analyze a reference HWPX template for template-preserving workflows"
167
+ )
168
+ parser.add_argument("input", help="Input HWPX path")
169
+ parser.add_argument("--json", action="store_true", help="Print machine-readable JSON summary")
170
+ parser.add_argument("--output-json", help="Write the JSON summary to a file")
171
+ parser.add_argument(
172
+ "--extract-dir",
173
+ help="Copy manifest, header, sections, version, and container.xml into a directory",
174
+ )
175
+ parser.add_argument("--extract-header", help="Copy the first header.xml part to a path")
176
+ parser.add_argument("--extract-section", help="Copy the first section XML part to a path")
177
+ parser.add_argument(
178
+ "--extract-section-dir",
179
+ help="Backward-compatible alias that copies section*.xml files into a directory",
180
+ )
181
+ args = parser.parse_args(argv)
182
+
183
+ input_path = Path(args.input)
184
+ if not input_path.is_file():
185
+ print(f"ERROR: file not found: {input_path}")
186
+ return 1
187
+
188
+ try:
189
+ analysis = analyze_template(input_path)
190
+ written = extract_template_parts(
191
+ input_path,
192
+ extract_dir=args.extract_dir,
193
+ extract_header=args.extract_header,
194
+ extract_section=args.extract_section,
195
+ extract_section_dir=args.extract_section_dir,
196
+ )
197
+ except Exception as exc:
198
+ print(f"ERROR: {exc}")
199
+ return 1
200
+
201
+ if args.json or args.output_json:
202
+ payload = json.dumps(asdict(analysis), ensure_ascii=False, indent=2)
203
+ if args.json:
204
+ print(payload)
205
+ if args.output_json:
206
+ output_path = Path(args.output_json)
207
+ output_path.parent.mkdir(parents=True, exist_ok=True)
208
+ output_path.write_text(payload, encoding="utf-8")
209
+ else:
210
+ _print_summary(analysis)
211
+
212
+ for path in written:
213
+ print(f"extracted: {path}")
214
+ return 0
215
+
216
+
217
+ if __name__ == "__main__": # pragma: no cover - CLI convenience
218
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 2.6
3
+ Version: 2.7.1
4
4
  Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
5
5
  Author: python-hwpx Maintainers
6
6
  License: Non-Commercial License
@@ -165,7 +165,8 @@ doc.save_to_path("결과물.hwpx")
165
165
  | 🔎 **객체 검색** | 태그/속성/XPath | 특정 요소 탐색, 주석 이터레이터 |
166
166
  | 🎨 **스타일 치환** | 서식 기반 필터 | 색상/밑줄/charPrIDRef 기반 Run 검색 및 교체 |
167
167
  | 📤 **내보내기** | 텍스트/HTML/Markdown | 문서 변환 출력 |
168
- | ✅ **유효성 검사** | XSD 스키마 | CLI(`hwpx-validate`) 및 API |
168
+ | ✅ **유효성 검사** | XSD + 패키지 구조 | CLI(`hwpx-validate`, `hwpx-validate-package`) 및 API |
169
+ | 🧰 **워크플로 도구** | unpack/pack/template analyze/page guard | 템플릿 보존형 XML-first 작업 보조 |
169
170
  | 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
170
171
  | 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
171
172
 
@@ -262,10 +263,15 @@ python-hwpx
262
263
  │ ├── body.py # 타입이 지정된 본문 모델
263
264
  │ └── common.py # 범용 XML ↔ 데이터클래스
264
265
  ├── hwpx.tools
266
+ │ ├── archive_cli # unpack/pack CLI 및 재패킹 메타데이터
265
267
  │ ├── text_extractor # 텍스트 추출 파이프라인
268
+ │ ├── text_extract_cli # 텍스트 추출 CLI
266
269
  │ ├── object_finder # 객체 탐색 유틸리티
267
270
  │ ├── exporter # 텍스트/HTML/Markdown 내보내기
268
- └── validator # 스키마 유효성 검사 (hwpx-validate CLI)
271
+ ├── validator # 스키마 유효성 검사 (hwpx-validate CLI)
272
+ │ ├── package_validator# ZIP/OPC/HWPX 구조 검사
273
+ │ ├── page_guard # layout-drift proxy
274
+ │ └── template_analyzer# 레퍼런스 문서 분석/추출
269
275
  └── hwpx.templates # 내장 빈 문서 템플릿
270
276
  ```
271
277
 
@@ -274,8 +280,26 @@ python-hwpx
274
280
  ```bash
275
281
  # HWPX 문서 스키마 유효성 검사
276
282
  hwpx-validate 문서.hwpx
283
+
284
+ # ZIP/OPC/HWPX 패키지 구조 검사
285
+ hwpx-validate-package 문서.hwpx
286
+
287
+ # HWPX 풀기 / 다시 묶기
288
+ hwpx-unpack 문서.hwpx ./unpacked
289
+ hwpx-pack ./unpacked ./repacked.hwpx
290
+
291
+ # 레퍼런스 템플릿 분석과 파트 추출
292
+ hwpx-analyze-template 문서.hwpx --extract-dir ./template-parts --json
293
+
294
+ # plain / markdown 텍스트 추출
295
+ hwpx-text-extract 문서.hwpx --format markdown --output 문서.md
296
+
297
+ # 레이아웃 드리프트 프록시 비교
298
+ hwpx-page-guard --reference 원본.hwpx --output 결과.hwpx
277
299
  ```
278
300
 
301
+ `hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 통계를 비교해 레이아웃 드리프트 위험을 탐지하는 프록시 도구입니다.
302
+
279
303
  ## 문서
280
304
 
281
305
  | | |
@@ -21,18 +21,20 @@ hwpx/oxml/section.py,sha256=WwxZ6PWPeMrj2L9mz4JlqFGXwd7E7qAuSBuM5dgRjZk,199
21
21
  hwpx/oxml/table.py,sha256=pdO2TTAcbEC6Z4cnaOnB-bcmuZ1KVado7J3RiY_zOfE,193
22
22
  hwpx/oxml/utils.py,sha256=to0yytS7vtLSvWl-dQyegT6MWClMK55b1Sp1uagEkI4,2591
23
23
  hwpx/tools/__init__.py,sha256=e1OaIVdbkmjTvLOzQ7qVRfuuQ1611225pNZByB2ln9w,1270
24
+ hwpx/tools/archive_cli.py,sha256=ih14UmayJTpOw14cRBmrPKbfMFNFfoyiHzFQ2CYt_sE,10419
24
25
  hwpx/tools/exporter.py,sha256=GcbNtV4rIWOJv5nBcgdX0yfkXQa-xQhfrCzXWgaNbTE,8862
25
26
  hwpx/tools/object_finder.py,sha256=vbZ8FuIpGF-2vpbWDeZWi4UgZ2-3PK_ddQCs0oq1dRw,13440
26
- hwpx/tools/package_validator.py,sha256=ZixhRjv_RYwBshk3NEJXyakRVsN7hM4WI47euvWFETU,7379
27
- hwpx/tools/page_guard.py,sha256=nholL2cMv249yieVBWGqW3WHqkFR1qVutBVz59V_kYo,8351
27
+ hwpx/tools/package_validator.py,sha256=JpXLcWxM0orD38G1v_eeWtuScDXtGqT7Tgh6GR-qOto,7420
28
+ hwpx/tools/page_guard.py,sha256=AaKDWet8QHduoB8smIUgBf8muYBuYenj-xAIv1uFbVA,10454
29
+ hwpx/tools/template_analyzer.py,sha256=QCqZRMxLFMTwoyYAzEmtzc8B4AwtqTMHV2hBCWXLKtQ,7919
28
30
  hwpx/tools/text_extract_cli.py,sha256=pIBMIFuFX10IEegw7fQ3gtUbQyjNgbAUYkQWh2S3aQs,2150
29
31
  hwpx/tools/text_extractor.py,sha256=r2OJRgDOiR6n14hXRcvkYuSFtEHpAV6jasHv-ZLHx1Y,24238
30
32
  hwpx/tools/validator.py,sha256=KThqBQKKQfZkuLMGtzONbPkzy877-2FgT22FHPmt_gI,5979
31
33
  hwpx/tools/_schemas/header.xsd,sha256=mJXuFMuHGT1JnFFaluUpYUglwjMCNlfbFCRVM26eHXE,664
32
34
  hwpx/tools/_schemas/section.xsd,sha256=MgvavVHG05RDfUnVPxVU10H4FQOja5ON04_m9Uk_m7E,522
33
- python_hwpx-2.6.dist-info/licenses/LICENSE,sha256=3F1-JUTcmjmxMpHGeB77ZzaSdhms3h8p1DBBa3lvV08,1609
34
- python_hwpx-2.6.dist-info/METADATA,sha256=9LDnFWuCNTEqQpF_WMyH7Mm3Ah7uYYnGazGI3C8pE20,12974
35
- python_hwpx-2.6.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
36
- python_hwpx-2.6.dist-info/entry_points.txt,sha256=KvwTIdfB-3OL8BEAmoiICdfqqndolqiET-zBEgbIyiM,216
37
- python_hwpx-2.6.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
38
- python_hwpx-2.6.dist-info/RECORD,,
35
+ python_hwpx-2.7.1.dist-info/licenses/LICENSE,sha256=3F1-JUTcmjmxMpHGeB77ZzaSdhms3h8p1DBBa3lvV08,1609
36
+ python_hwpx-2.7.1.dist-info/METADATA,sha256=o8tMskFmig6pQFTG92dD5vXNKoGQLps3tW3OfkPZGGk,14238
37
+ python_hwpx-2.7.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
38
+ python_hwpx-2.7.1.dist-info/entry_points.txt,sha256=zKneV9VceQKwbJUo-mUUbwRmQjNyNSzrv44XuMhsaUU,368
39
+ python_hwpx-2.7.1.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
40
+ python_hwpx-2.7.1.dist-info/RECORD,,
@@ -1,5 +1,8 @@
1
1
  [console_scripts]
2
+ hwpx-analyze-template = hwpx.tools.template_analyzer:main
3
+ hwpx-pack = hwpx.tools.archive_cli:pack_main
2
4
  hwpx-page-guard = hwpx.tools.page_guard:main
3
5
  hwpx-text-extract = hwpx.tools.text_extract_cli:main
6
+ hwpx-unpack = hwpx.tools.archive_cli:unpack_main
4
7
  hwpx-validate = hwpx.tools.validator:main
5
8
  hwpx-validate-package = hwpx.tools.package_validator:main