diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cae9b4b..5480375b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,9 @@ 끝난다. 그래서 열은 그 열 낱칸 가운데 가장 넓은 것만큼 넓고, 여러 열을 합친 칸이 그 열들보다 넓으면 넘침이 마지막 열에 붙는다. - 칸은 첫 열의 시작부터 마지막 열의 끝까지다. 다른 행 때문에 자기 폭보다 넓어질 수 있다. - - 줄 캐시가 없는 칸의 줄 나눔과 행 높이에 쓴다(`_grid_widths`). + - 줄 캐시가 없는 칸의 줄 나눔과 행 높이에 쓴다(`hwpx.oxml.table_sizes.grid_widths_of`). + - FormFit(`resolve_slot_metrics`)도 칸의 자리를 그 폭에서 잰다(`SlotMetrics.raw_width`). 전에는 칸 자신의 + `hp:cellSz` 폭이었다. - 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 글자처럼 둔 표 옆에 글 앞·뒤로 둔 개체가 있는 문단을 지원 밖(`a nested table`)으로 두던 것을 고친다. - 그런 개체는 자리를 차지하지 않으므로, 그 문단은 표 혼자인 문단처럼 표 높이의 한 줄이다. diff --git a/docs/architecture/module-ownership.json b/docs/architecture/module-ownership.json index 98475a17..266e3e9d 100644 --- a/docs/architecture/module-ownership.json +++ b/docs/architecture/module-ownership.json @@ -553,7 +553,7 @@ "path": "src/hwpx/oxml/table_sizes.py", "disposition": "core", "approvedBy": "hancom-distribute-cell-width", - "rationale": "How a table shares out its width and height among its cells: the bodies of HwpxOxmlTable.set_column_widths, equalize_column_widths (Hancom's distribute-cell-width rule: each row splits the table width among its own cells, the width rounded up to a common multiple of every row's cell count, the column grid rebuilt from the new cell edges) and equalize_row_heights; and the bodies of HwpxOxmlTableCell.margins/set_margins (the effective inner margins: the table's hp:inMargin unless the cell's hasMargin is on, then its hp:cellMargin), assigned onto the cell class as attributes. That margin rule (effective_cell_margin_source / cell_margins_of) is the one every other reader uses -- form fit, the nested-table default width, the table height bound, the layout preview, the template analyzer and the byte-level cell fill -- so the engine reports one value per cell. A new module rather than more table.py lines: the open table fixes together would take that owner file past the 1,600-line cap; the table methods keep their documentation and delegate. Format-level only." + "rationale": "How a table shares out its width and height among its cells: the bodies of HwpxOxmlTable.set_column_widths, equalize_column_widths (Hancom's distribute-cell-width rule: each row splits the table width among its own cells, the width rounded up to a common multiple of every row's cell count, the column grid rebuilt from the new cell edges) and equalize_row_heights; and the bodies of HwpxOxmlTableCell.margins/set_margins (the effective inner margins: the table's hp:inMargin unless the cell's hasMargin is on, then its hp:cellMargin), assigned onto the cell class as attributes. That margin rule (effective_cell_margin_source / cell_margins_of) is the one every other reader uses -- form fit, the nested-table default width, the table height bound, the layout preview, the template analyzer and the byte-level cell fill -- so the engine reports one value per cell. grid_widths_of gives each cell's width on the column grid Hancom lays text out on (columns stacked from the left, each ending at the farthest of its cells' starts plus widths and the cell spacing), read by the page estimate and form fit. A new module rather than more table.py lines: the open table fixes together would take that owner file past the 1,600-line cap; the table methods keep their documentation and delegate. Format-level only." }, { "path": "src/hwpx/oxml/paragraph_heading.py", diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index 7ab7d22b..73183e24 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -34,7 +34,7 @@ from functools import lru_cache from typing import Any, Literal -from ..oxml.table_sizes import cell_margins_of +from ..oxml.table_sizes import cell_margins_of, grid_widths_of # Advance width as a fraction of the em (font height in HWPUNIT). Hangul/wide are # exact (full-width cells); the Latin/digit/punct values are conservative class @@ -825,7 +825,7 @@ class SlotMetrics: available_width: float # usable inner width after margins + safety font_pt: float max_lines: int = 1 - raw_width: float | None = None # cellSz.width before margins (diagnostics) + raw_width: float | None = None # the cell's width on the table grid before margins (diagnostics) source: str = "cell" # Vertical budget. ``available_height`` is the usable inner height (HWPUNIT) # after top/bottom cell margins + the safety inset. ``None`` means the vertical @@ -1141,6 +1141,19 @@ def _local_name(tag: object) -> str: return str(tag).rsplit("}", 1)[-1] +def _grid_width(cell: object) -> float: + """The cell's width on its table's column grid, as Hancom lays its text out: wider than its own + ``hp:cellSz`` when another row's cells push its columns' ends apart (see + :func:`hwpx.oxml.table_sizes.grid_widths_of`); its own width without a table to read.""" + + own = float(getattr(cell, "width", 0) or 0) + element = getattr(cell, "element", None) + table_element = getattr(getattr(cell, "table", None), "element", None) + if element is None or table_element is None: + return own + return float(grid_widths_of(table_element).get(element, own)) + + def _effective_cell_margins(cell: object) -> tuple[int, int, int, int]: """(left, right, top, bottom) the cell is laid out with, as ``cell.margins`` reads them. @@ -1434,7 +1447,9 @@ def resolve_slot_metrics( ) -> SlotMetrics: """Build :class:`SlotMetrics` from a live table cell. - ``available_width = max(cellSz.width - margin.L - margin.R, MIN_LINE_WIDTH) * safety``, + ``available_width = max(width - margin.L - margin.R, MIN_LINE_WIDTH) * safety``, the width being the + cell's on the table's column grid (its ``cellSz.width`` unless another row's cells push its columns' ends + apart, see :func:`hwpx.oxml.table_sizes.grid_widths_of`), where the margins are the cell's effective ones (``cell.margins``: the table's ``hp:inMargin`` unless ``hasMargin`` is on) — verified against Hancom's own ``lineSeg/@horzsize`` (±10 HWPUNIT on 82% of @@ -1486,7 +1501,7 @@ def _cell_slot( ) -> SlotMetrics: """The width, font and text style of a cell's slot, without a height budget.""" - raw_width = float(getattr(cell, "width", 0) or 0) + raw_width = _grid_width(cell) element = getattr(cell, "element", None) left, right, _top, _bottom = _effective_cell_margins(cell) line = max(raw_width - left - right, 0.0) * safety diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index ff81f213..5e045841 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -182,7 +182,7 @@ from ..oxml.section import _remove_short_paragraph_layout_cache from ..oxml.header_part import HwpxOxmlHeader from ..oxml.section_format import _drawn_page_size -from ..oxml.table_sizes import cell_margins_of +from ..oxml.table_sizes import cell_margins_of, grid_widths_of if TYPE_CHECKING: from ..document import HwpxDocument @@ -786,7 +786,7 @@ def _table_rows(measure: _Measure, table: Any) -> tuple[list[_Row], list[tuple[i nested: set[int] = set() # rows with a cell holding a table headers: set[int] = set() # rows with a header cell of their own firsts: dict[int, int] = {} # each row's tallest first line of a cell of its own - widths = _grid_widths(table) + widths = grid_widths_of(table) for tc in (tc for tr in table.findall(f"{HP}tr") for tc in tr.findall(f"{HP}tc")): row, span = _cell_row(measure, table, tc, widths.get(tc)), _row_span(tc) address = tc.find(f"{HP}cellAddr") @@ -823,33 +823,6 @@ def _table_rows(measure: _Measure, table: Any) -> tuple[list[_Row], list[tuple[i [(place[first], span, cell) for first, span, cell in cells]) -def _grid_widths(table: Any) -> dict[Any, int]: - """Each cell's width on the table's column grid, as Hancom lays its text out: from the left, each column - starts at the farthest of the ends its cells to the left reach (a cell's start plus its ``hp:cellSz`` width - and the table's cell spacing) -- a column as wide as its widest cell of its own, and a merged cell wider - than its columns widening the last of them -- and a cell reaches from its first column's start to its last - column's end, wider than its own width when another row's cells push those ends apart. Empty for a table - whose cells give no column starts to stand on.""" - - spacing = int(table.get("cellSpacing", 0) or 0) - places: dict[Any, tuple[int, int, int]] = {} # cell: (first column, column after its last, width) - for tc in (tc for tr in table.findall(f"{HP}tr") for tc in tr.findall(f"{HP}tc")): - address, span = tc.find(f"{HP}cellAddr"), tc.find(f"{HP}cellSpan") - if address is None: - return {} - first = int(address.get("colAddr", 0)) - places[tc] = (first, first + (1 if span is None else int(span.get("colSpan", 1))), - int(tc.find(f"{HP}cellSz").get("width", 0))) - starts = {0: 0} - for end in sorted({stop for _, stop, _ in places.values()}): - reach = [starts[first] + width + spacing for first, stop, width in places.values() - if stop == end and first in starts] - if not reach: - return {} - starts[end] = max(reach) - return {tc: starts[stop] - starts[first] - spacing for tc, (first, stop, _) in places.items() if first in starts} - - def _row_span(cell: Any) -> int: span = cell.find(f"{HP}cellSpan") return 1 if span is None else int(span.get("rowSpan", 1)) diff --git a/src/hwpx/oxml/table_sizes.py b/src/hwpx/oxml/table_sizes.py index 83552e24..839987aa 100644 --- a/src/hwpx/oxml/table_sizes.py +++ b/src/hwpx/oxml/table_sizes.py @@ -33,6 +33,7 @@ "set_cell_margins", "effective_cell_margin_source", "set_column_widths", + "grid_widths_of", ] _MARGIN_SIDES = ("left", "right", "top", "bottom") @@ -304,6 +305,34 @@ def cell_margins_of(cell_element: Any, table_element: Any | None) -> CellMargins return CellMargins(*(_margin_value(source.get(side)) for side in _MARGIN_SIDES)) +def grid_widths_of(table_element: Any) -> dict[Any, int]: + """Each ``hp:tc`` of a raw ``hp:tbl`` element and its width on the table's column grid, as Hancom lays its + text out (the page estimate and form fit read it). From the left, each column starts at the farthest of the + ends its cells to the left reach (a cell's start plus its ``hp:cellSz`` width and the table's cell spacing): + a column is as wide as its widest cell of its own, and a merged cell wider than its columns widens the last + of them. A cell reaches from its first column's start to its last column's end, wider than its own width + when another row's cells push those ends apart. Empty for a table whose cells give no column starts to + stand on.""" + + spacing = int(table_element.get("cellSpacing", 0) or 0) + places: dict[Any, tuple[int, int, int]] = {} # cell: (first column, column after its last, width) + for tc in (tc for tr in table_element.findall(f"{_HP}tr") for tc in tr.findall(f"{_HP}tc")): + address, span = tc.find(f"{_HP}cellAddr"), tc.find(f"{_HP}cellSpan") + if address is None: + return {} + first = int(address.get("colAddr", 0)) + places[tc] = (first, first + (1 if span is None else int(span.get("colSpan", 1))), + int(tc.find(f"{_HP}cellSz").get("width", 0))) + starts = {0: 0} + for end in sorted({stop for _, stop, _ in places.values()}): + reach = [starts[first] + width + spacing for first, stop, width in places.values() + if stop == end and first in starts] + if not reach: + return {} + starts[end] = max(reach) + return {tc: starts[stop] - starts[first] - spacing for tc, (first, stop, _) in places.items() if first in starts} + + def _margin_value(raw: str | None) -> int: try: return int(raw or 0) diff --git a/tests/test_form_fit_hancom_rules.py b/tests/test_form_fit_hancom_rules.py index a42458a2..68d41025 100644 --- a/tests/test_form_fit_hancom_rules.py +++ b/tests/test_form_fit_hancom_rules.py @@ -488,6 +488,30 @@ def test_a_cell_holds_lines_up_to_its_stored_height() -> None: assert budgets == [1, 2, 2] +@pytest.mark.parametrize( + ("fixture", "row", "column", "width"), + [ + ("pages_table_column_as_wide_as_its_widest_cell", 0, 1, 32000), # 30000 in a column of 30000, 32000, + # 31000 + ("pages_table_columns_stacked_from_the_left", 0, 1, 30500), # 30000 after 8000, 30500 after 7683 below + ("pages_table_merged_cell_widening_its_last_column", 0, 2, 21000), # 20000 after 10000 under a cell + # merged over both, 31000 + ], +) +def test_a_cell_slot_is_as_wide_as_its_columns_on_the_table_grid(fixture: str, row: int, column: int, + width: int) -> None: + # Hancom laid these cells' lines out at the width of their columns on the table grid, not their own + # hp:cellSz: the slot is measured there too, as wide as Hancom's lines (within its rounding of the margins). + doc = HwpxDocument.open((Path(__file__).parent / "fixtures" / "hancom_saved" / f"{fixture}.hwpx").read_bytes()) + table = next(table for paragraph in doc.paragraphs for table in paragraph.tables) + cell = table.cell(row, column) + slot = resolve_slot_metrics(cell, doc, safety=1.0) + segment = cell.element.find(f"{HP}subList/{HP}p/{HP}linesegarray/{HP}lineseg") + + assert slot.raw_width == width + assert abs(slot.available_width - int(segment.get("horzsize"))) <= 2 + + def test_each_line_spacing_type_advances_a_line_as_hancom_does() -> None: def pitch(kind: str, value: float) -> float: return SlotMetrics(available_width=5000.0, font_pt=10.0, line_spacing=(kind, value)).line_height()