Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@
# Their bundled copies stay byte-identical to the docs/ originals (scripts/sync_contract_docs.py --check).
/src/hwpx/data/contract_docs/recipes-traversal.md text eol=lf
/src/hwpx/data/contract_docs/mutation-semantics.md text eol=lf
# Compressed data: never converted.
/src/hwpx/form_fit/_glyph_table.json.gz binary
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@

### 고침

- 배포판에 든 파일이 모두 256 KiB 아래로 내려간다. Claude 플러그인은 엔진 배포판을 풀어 싣는데, 플러그인 디렉터리가 파일
하나에서 256 KiB까지만 읽어 6.9.0부터 플러그인의 엔진이 6.8.0에 멈춰 있었다(hwpx-plugins #66). FormFit 글리프
표(`form_fit/_glyph_table.py`, 454 KiB)는 gzip으로 압축한 JSON 자료 파일 `_glyph_table.json.gz`(65 KiB)로 싣고
모듈을 가져올 때 읽는다. 표의 값과 순서는 그대로다. 쪽 수 추정(`layout/pages.py`, 325 KiB)은 아래쪽 절반을
`layout/_page_measure.py`로 옮겼다. 코드는 한 줄도 바꾸지 않았고 `hwpx.layout.pages`에서 전과 같은 이름을 쓴다.
배포 파일이 한도에 닿으면 실패하고 80%를 넘으면 경고하는 검사(`tests/test_distribution_file_sizes.py`)를 더했다.
- 표 행·열·칸 구조 편집(객체 경로와 `apply_table_ops`)이 쪽을 넘어 나뉜 표의 저장 높이(`hp:sz`)를 그대로 둔다. 한/글은
그런 표에 첫 쪽 조각의 높이만 저장하므로(같은 행의 표가 놓인 자리에 따라 30463·17551·5865로 저장됨) 행 높이 증감을
더할 근거가 없다. 전에는 증감을 그대로 더해, 평가계획 채우기가 13행 표(행 28762, 저장 13222)에서 9행을 지우면
Expand Down
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,18 @@
- 점진 변환 범위(현재: `src/hwpx/document.py`, `src/hwpx/oxml/document.py`)는 CI에서 다음 항목으로 검증합니다.
- `scripts/check_typing_generics_scope.py`: `List`/`Dict`/`Tuple` 별칭 사용 금지 확인
- `mypy`, `pyright`: 지정된 파일 범위 타입 검사

## 배포 파일 크기

Claude 플러그인은 릴리스한 wheel을 풀어서 싣고, 플러그인 디렉터리는 파일 하나에서 256 KiB까지만 읽습니다
(hwpx-plugins `scripts/vendor_claude_engine.py`의 `MAX_FILE_BYTES`). 묶음 전체는 512개 파일까지입니다.

- `tests/test_distribution_file_sizes.py`는 `src/hwpx`와 빌드한 wheel의 파일이 256 KiB에 닿으면 실패하고,
80%(약 205 KiB)를 넘으면 경고합니다. 경고가 뜨면 한도에 닿기 전에 나누세요. 파일 수 한도가 있으므로 여러
모듈보다 새 모듈 하나로 나누는 편이 낫습니다.
- FormFit 글리프 표는 `scripts/glyph_table_source.py`(읽을 수 있는 Python, 배포하지 않음)에서 고칩니다. 고친 뒤
`python scripts/build_glyph_table.py`를 돌려 `src/hwpx/form_fit/_glyph_table.json.gz`를 다시 만들고 둘을 함께
커밋하세요. `tests/test_glyph_table_data.py`가 두 파일의 표를 값 하나하나 비교합니다.
- 쪽 수 추정은 `layout/pages.py`(공개 이름, 쪽·단 흐름)와 `layout/_page_measure.py`(상수, 문단·표 모델)로 나뉘어
있습니다. `_page_measure.py`는 `pages.py`를 참조하지 않고, 거기에 새로 정의한 이름은 `pages.py`의 import
목록에도 넣습니다(`tests/test_layout_pages_split.py`).
8 changes: 7 additions & 1 deletion docs/architecture/module-ownership.json
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,12 @@
"approvedBy": "owner-page-estimate-2026-09-29",
"rationale": "Experimental page estimate (re-exported from hwpx.experimental): follows each body paragraph onto its page and column from the document alone -- lines from a paragraph's valid layout cache or FormFit line breaking, line and paragraph spacing, page/column breaks and paragraph break rules, equal columns, inline and flowing tables, top-and-bottom objects and footnotes -- and reports anything else as unsupported with a reason. Reads OXML only; writes nothing."
},
{
"path": "src/hwpx/layout/_page_measure.py",
"disposition": "core",
"approvedBy": "owner-audit-fix-2026-11-10 (hwpx-plugins issue 66)",
"rationale": "The lower half of the experimental page estimate, moved out of layout/pages.py unchanged so that no shipped file passes 256 KiB (the most a plugin directory reads of one file): the constants, the paragraph and table model (_Measure, cell rows and lines, object extents) and the rows of a flowing table. Refers to nothing in layout/pages.py, which imports every name it defines and keeps the public names and the flow over pages and columns. Reads OXML only; writes nothing."
},
{
"path": "src/hwpx/layout/line_cache.py",
"disposition": "core",
Expand All @@ -739,7 +745,7 @@
"path": "src/hwpx/form_fit/_glyph_table.py",
"disposition": "core",
"approvedBy": "formfit-glyph-widths",
"rationale": "FormFit glyph widths beyond measure._GLYPHS: per face, the design advance of each glyph of the KS X 1001 symbols, the full-width forms and eight common Unicode blocks, read from the font files, and the face Hancom lays a glyph out from when a face lacks it. Data only; imports nothing."
"rationale": "FormFit glyph widths beyond measure._GLYPHS: per face, the design advance of each glyph of the KS X 1001 symbols, the full-width forms and eight common Unicode blocks, read from the font files, and the face Hancom lays a glyph out from when a face lacks it. Data only: reads the tables from _glyph_table.json.gz beside it (gzip-compressed JSON written by scripts/build_glyph_table.py from the readable scripts/glyph_table_source.py) when imported, so that no shipped file passes 256 KiB; imports only the standard library."
},
{
"path": "src/hwpx/_trailing_table_text.py",
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ packages = [
# worse than a longer list. Adding a package here is a deliberate act.
[tool.setuptools.package-data]
"hwpx" = ["py.typed"]
# FormFit's glyph tables: data, not source, so that no shipped file passes 256 KiB (see
# scripts/build_glyph_table.py and tests/test_distribution_file_sizes.py).
"hwpx.form_fit" = ["_glyph_table.json.gz"]
"hwpx.tools" = ["_schemas/*.xsd"]
"hwpx.data" = ["Skeleton.hwpx", "contract_docs/*.md"]

Expand Down
117 changes: 117 additions & 0 deletions scripts/build_glyph_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Write FormFit's glyph tables as the data file the package ships.

The tables are kept as readable Python in ``scripts/glyph_table_source.py`` (about 450 KiB and growing), which
does not ship. The package carries them as ``src/hwpx/form_fit/_glyph_table.json.gz`` (gzip-compressed JSON,
about 65 KiB) so that no shipped file passes 256 KiB, the most a Claude plugin directory reads of one file
(hwpx-plugins issue 66). ``hwpx.form_fit._glyph_table`` reads the data file when it is imported.

python scripts/build_glyph_table.py # after editing the source: rewrite the data file
python scripts/build_glyph_table.py --check # exit 1 when the data file does not hold the source's tables

The data file is compared by content, not by bytes (zlib builds compress differently), and is rewritten only
when its content differs. ``tests/test_glyph_table_data.py`` compares every table with the source.
"""

from __future__ import annotations

import argparse
import gzip
import importlib.util
import json
import pathlib
import sys
from typing import Any

ROOT = pathlib.Path(__file__).resolve().parent.parent
SOURCE = ROOT / "scripts" / "glyph_table_source.py"
DATA = ROOT / "src" / "hwpx" / "form_fit" / "_glyph_table.json.gz"
SCHEMA = "python-hwpx.glyph-table/v1"
#: The tables, in the order the data file holds them, and the shape of each face's value.
TABLES: dict[str, str] = {
"UNITS_PER_EM": "int",
"FALLBACK": "str",
"VERTICAL_METRICS": "pair",
"LINE_HEIGHTS": "int-by-int",
"HANGUL_ADVANCES": "str-by-int",
"ADVANCES": "str-by-int",
"ITALIC_ADVANCES": "str-by-int",
"BOLD_ITALIC_ADVANCES": "str-by-int",
}


def load_source(path: pathlib.Path = SOURCE) -> dict[str, dict[str, Any]]:
"""The tables of the source module, by name."""

spec = importlib.util.spec_from_file_location("_glyph_table_source", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
names = {name for name in vars(module) if name.isupper() and not name.startswith("_")}
if names != set(TABLES):
raise SystemExit(f"{path.name} defines {sorted(names)}; build_glyph_table.TABLES lists {sorted(TABLES)}")
return {name: getattr(module, name) for name in TABLES}


def _checked(name: str, kind: str, face: object, value: object) -> Any:
"""*value* as the data file holds it, refusing anything the loader would not give back the same."""

def fail() -> SystemExit:
return SystemExit(f"{name}[{face!r}] = {value!r:.80} is not {kind}")

if type(face) is not str:
raise fail()
if kind in ("int", "str"):
if type(value) is not (int if kind == "int" else str):
raise fail()
return value
if kind == "pair":
if type(value) is not tuple or len(value) != 2 or any(type(item) is not int for item in value):
raise fail()
return list(value)
inner = int if kind == "int-by-int" else str
if type(value) is not dict or any(type(k) is not int or type(v) is not inner for k, v in value.items()):
raise fail()
return [[k, v] for k, v in value.items()]


def encode(tables: dict[str, dict[str, Any]]) -> bytes:
"""The JSON text of *tables*: every table a list of ``[face, value]`` in the source's order."""

payload = {
"schema": SCHEMA,
"tables": {name: [[face, _checked(name, kind, face, value)] for face, value in tables[name].items()]
for name, kind in TABLES.items()},
}
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")


def stored(path: pathlib.Path = DATA) -> bytes | None:
"""The JSON text the data file holds, or None when it is missing or unreadable."""

try:
return gzip.decompress(path.read_bytes())
except (OSError, EOFError, gzip.BadGzipFile):
return None


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--check", action="store_true", help="only compare the data file with the source")
args = parser.parse_args(argv)
text = encode(load_source())
if stored() == text:
print(f"[OK] {DATA.relative_to(ROOT).as_posix()} holds the source's tables ({DATA.stat().st_size} bytes)")
return 0
if args.check:
print(f"[FAIL] {DATA.relative_to(ROOT).as_posix()} differs from {SOURCE.relative_to(ROOT).as_posix()}; "
"run python scripts/build_glyph_table.py")
return 1
DATA.write_bytes(gzip.compress(text, compresslevel=9, mtime=0))
print(f"[OK] wrote {DATA.relative_to(ROOT).as_posix()} ({DATA.stat().st_size} bytes, {len(text)} of JSON)")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading