python-hwpx 3.4.1__py3-none-any.whl → 3.5.0__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.
- hwpx/form_fit/engine.py +225 -3
- hwpx/form_fit/measure.py +155 -0
- {python_hwpx-3.4.1.dist-info → python_hwpx-3.5.0.dist-info}/METADATA +2 -2
- {python_hwpx-3.4.1.dist-info → python_hwpx-3.5.0.dist-info}/RECORD +9 -9
- {python_hwpx-3.4.1.dist-info → python_hwpx-3.5.0.dist-info}/WHEEL +0 -0
- {python_hwpx-3.4.1.dist-info → python_hwpx-3.5.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-3.4.1.dist-info → python_hwpx-3.5.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-3.4.1.dist-info → python_hwpx-3.5.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-3.4.1.dist-info → python_hwpx-3.5.0.dist-info}/top_level.txt +0 -0
hwpx/form_fit/engine.py
CHANGED
|
@@ -17,13 +17,32 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
from dataclasses import dataclass, replace
|
|
19
19
|
|
|
20
|
-
from .measure import
|
|
20
|
+
from .measure import (
|
|
21
|
+
GROSS_ROW_GROWTH_FACTOR,
|
|
22
|
+
MIN_ROW_GROWTH_LINES,
|
|
23
|
+
SlotMetrics,
|
|
24
|
+
estimate_text_width,
|
|
25
|
+
measure,
|
|
26
|
+
)
|
|
21
27
|
from .policy import FitPolicy
|
|
22
28
|
from .report import FitResult
|
|
23
29
|
|
|
24
30
|
# Font ladder step (points) used when shrinking.
|
|
25
31
|
_SHRINK_STEP_PT = 0.5
|
|
26
32
|
|
|
33
|
+
# The vertical room could not be measured (merged cell, no height, or an auto-grow
|
|
34
|
+
# floor); the fit is width-only and the oracle must confirm the vertical fit.
|
|
35
|
+
_HEIGHT_UNAVAILABLE_NOTE = (
|
|
36
|
+
"cell height is not a usable budget (merged/auto-grow); fit is width-only — "
|
|
37
|
+
"render oracle should confirm the vertical fit"
|
|
38
|
+
)
|
|
39
|
+
# The content needs more vertical room than the authored cell height, but not
|
|
40
|
+
# grossly — the row will grow modestly. Reported (never silent), deferred to oracle.
|
|
41
|
+
_ROW_GROWTH_NOTE = (
|
|
42
|
+
"value needs more lines than the cell height budgets; the row will grow — "
|
|
43
|
+
"render oracle should confirm no page shift"
|
|
44
|
+
)
|
|
45
|
+
|
|
27
46
|
|
|
28
47
|
@dataclass(slots=True)
|
|
29
48
|
class FitEngine:
|
|
@@ -63,7 +82,7 @@ class FitEngine:
|
|
|
63
82
|
wrap_slot = replace(slot, max_lines=wrap_lines)
|
|
64
83
|
measurement = measure(value, wrap_slot)
|
|
65
84
|
if measurement.fits:
|
|
66
|
-
|
|
85
|
+
result = FitResult(
|
|
67
86
|
ok=True,
|
|
68
87
|
value=value,
|
|
69
88
|
applied_value=value,
|
|
@@ -78,11 +97,15 @@ class FitEngine:
|
|
|
78
97
|
warnings=list(measurement.notes),
|
|
79
98
|
field_id=field_id,
|
|
80
99
|
)
|
|
100
|
+
return self._apply_height_budget(result, value, slot, policy, wrap_lines, field_id)
|
|
81
101
|
|
|
82
102
|
if policy.may_shrink:
|
|
83
103
|
shrunk = self._try_shrink(value, slot, policy, wrap_lines)
|
|
84
104
|
if shrunk is not None:
|
|
85
|
-
|
|
105
|
+
shrunk = replace(shrunk, field_id=field_id)
|
|
106
|
+
return self._apply_height_budget(
|
|
107
|
+
shrunk, value, slot, policy, wrap_lines, field_id
|
|
108
|
+
)
|
|
86
109
|
|
|
87
110
|
# Still overflowing → terminal overflow action (confidence-gated).
|
|
88
111
|
return self._overflow(value, wrap_slot, policy, field_id, measurement)
|
|
@@ -212,6 +235,205 @@ class FitEngine:
|
|
|
212
235
|
candidate = round(candidate - _SHRINK_STEP_PT, 4)
|
|
213
236
|
return best
|
|
214
237
|
|
|
238
|
+
# -- vertical (row-height) budget ------------------------------------- #
|
|
239
|
+
def _apply_height_budget(
|
|
240
|
+
self,
|
|
241
|
+
result: FitResult,
|
|
242
|
+
value: str,
|
|
243
|
+
slot: SlotMetrics,
|
|
244
|
+
policy: FitPolicy,
|
|
245
|
+
wrap_lines: int,
|
|
246
|
+
field_id: str | None,
|
|
247
|
+
) -> FitResult:
|
|
248
|
+
"""Overlay the authored row-height budget on a width-fitting *result*.
|
|
249
|
+
|
|
250
|
+
The width ladder decides horizontal fit; this decides whether the value
|
|
251
|
+
also sits inside the cell's *vertical* room, so a filled row does not
|
|
252
|
+
silently grow and shift the page (the dominant M9 form-fill defect).
|
|
253
|
+
|
|
254
|
+
Honesty contract (mirrors the width path, grounded in the S-085 P0/P1
|
|
255
|
+
measurement): the authored ``cellSz.height`` is an unreliable proxy in wild
|
|
256
|
+
forms (auto-grow, tiny floors, merges), so a *modest* vertical overflow is
|
|
257
|
+
reported and deferred to the render oracle; only a *gross* balloon shrinks
|
|
258
|
+
or fails closed. ``allow_row_expand`` / ``expand_row`` opt out entirely.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
if slot.available_height is None:
|
|
262
|
+
if slot.height_unavailable and result.ok:
|
|
263
|
+
result.warnings.append(_HEIGHT_UNAVAILABLE_NOTE)
|
|
264
|
+
return result
|
|
265
|
+
if policy.allow_row_expand or policy.mode == "expand_row":
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
font = result.font_pt if result.font_pt is not None else slot.font_pt
|
|
269
|
+
wrapped = result.lines if result.lines is not None else 1
|
|
270
|
+
verdict = self._height_verdict(slot, wrapped, font)
|
|
271
|
+
if verdict == "fits":
|
|
272
|
+
return result
|
|
273
|
+
if verdict == "modest":
|
|
274
|
+
# S-085 P1 calibration round 2 (differential-measured): the modest
|
|
275
|
+
# band is where pages actually shift, so try to shrink INTO the
|
|
276
|
+
# budget first; only defer to the oracle when no font >= min can
|
|
277
|
+
# land the value fully inside the authored height.
|
|
278
|
+
if policy.may_shrink:
|
|
279
|
+
shrunk = self._try_shrink_for_height(
|
|
280
|
+
value, slot, policy, wrap_lines, field_id, accept_modest=False
|
|
281
|
+
)
|
|
282
|
+
if shrunk is not None:
|
|
283
|
+
return shrunk
|
|
284
|
+
result.overflow_detected = True
|
|
285
|
+
result.warnings.append(_ROW_GROWTH_NOTE)
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
# Gross vertical balloon: shrink (prefer a full fit, else the least
|
|
289
|
+
# growth), else fail-closed.
|
|
290
|
+
if policy.may_shrink:
|
|
291
|
+
shrunk = self._try_shrink_for_height(
|
|
292
|
+
value, slot, policy, wrap_lines, field_id, accept_modest=True
|
|
293
|
+
)
|
|
294
|
+
if shrunk is not None:
|
|
295
|
+
return shrunk
|
|
296
|
+
return self._overflow_height(value, slot, policy, field_id, wrapped, font)
|
|
297
|
+
|
|
298
|
+
def _height_verdict(self, slot: SlotMetrics, wrapped_lines: int, font_pt: float) -> str:
|
|
299
|
+
"""Classify vertical fit as ``fits`` / ``modest`` / ``gross``."""
|
|
300
|
+
|
|
301
|
+
budget = slot.height_lines(font_pt)
|
|
302
|
+
if budget is None or wrapped_lines <= budget:
|
|
303
|
+
return "fits"
|
|
304
|
+
optimistic = slot.height_lines_optimistic(font_pt) or budget
|
|
305
|
+
if (
|
|
306
|
+
wrapped_lines > optimistic * GROSS_ROW_GROWTH_FACTOR
|
|
307
|
+
and (wrapped_lines - optimistic) >= MIN_ROW_GROWTH_LINES
|
|
308
|
+
):
|
|
309
|
+
return "gross"
|
|
310
|
+
return "modest"
|
|
311
|
+
|
|
312
|
+
def _try_shrink_for_height(
|
|
313
|
+
self,
|
|
314
|
+
value: str,
|
|
315
|
+
slot: SlotMetrics,
|
|
316
|
+
policy: FitPolicy,
|
|
317
|
+
wrap_lines: int,
|
|
318
|
+
field_id: str | None,
|
|
319
|
+
*,
|
|
320
|
+
accept_modest: bool,
|
|
321
|
+
) -> FitResult | None:
|
|
322
|
+
"""Largest font (>= min) that lands the value INSIDE the height budget.
|
|
323
|
+
|
|
324
|
+
Walks the shrink ladder preferring a full vertical fit ("fits"). When
|
|
325
|
+
``accept_modest`` is true and no font achieves a full fit, the largest
|
|
326
|
+
candidate whose growth is merely *modest* (never gross) is returned as
|
|
327
|
+
the least-damage fallback, reported and deferred to the oracle.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
ceiling = policy.max_font_pt or slot.font_pt
|
|
331
|
+
candidate = min(slot.font_pt, ceiling)
|
|
332
|
+
modest_candidate: tuple[float, int] | None = None
|
|
333
|
+
while candidate >= policy.min_font_pt - 1e-9:
|
|
334
|
+
trial = replace(slot, font_pt=candidate, max_lines=wrap_lines)
|
|
335
|
+
m = measure(value, trial)
|
|
336
|
+
if m.fits and m.confidence == "high":
|
|
337
|
+
verdict = self._height_verdict(slot, m.lines, candidate)
|
|
338
|
+
if verdict == "fits":
|
|
339
|
+
return self._shrunk_result(
|
|
340
|
+
value, slot, candidate, m.lines, wrap_lines, field_id,
|
|
341
|
+
modest=False,
|
|
342
|
+
)
|
|
343
|
+
if verdict == "modest" and modest_candidate is None:
|
|
344
|
+
modest_candidate = (candidate, m.lines)
|
|
345
|
+
candidate = round(candidate - _SHRINK_STEP_PT, 4)
|
|
346
|
+
if accept_modest and modest_candidate is not None:
|
|
347
|
+
font_pt, lines = modest_candidate
|
|
348
|
+
return self._shrunk_result(
|
|
349
|
+
value, slot, font_pt, lines, wrap_lines, field_id, modest=True
|
|
350
|
+
)
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def _shrunk_result(
|
|
354
|
+
self,
|
|
355
|
+
value: str,
|
|
356
|
+
slot: SlotMetrics,
|
|
357
|
+
candidate: float,
|
|
358
|
+
lines: int,
|
|
359
|
+
wrap_lines: int,
|
|
360
|
+
field_id: str | None,
|
|
361
|
+
*,
|
|
362
|
+
modest: bool,
|
|
363
|
+
) -> FitResult:
|
|
364
|
+
changes: dict[str, object] = {}
|
|
365
|
+
warnings: list[str] = []
|
|
366
|
+
if candidate != slot.font_pt:
|
|
367
|
+
changes = {
|
|
368
|
+
"font_pt": round(candidate, 2),
|
|
369
|
+
"from_font_pt": round(slot.font_pt, 2),
|
|
370
|
+
}
|
|
371
|
+
warnings.append(
|
|
372
|
+
f"font shrunk {slot.font_pt:g}pt → {candidate:g}pt to fit the row height"
|
|
373
|
+
)
|
|
374
|
+
if wrap_lines > 1 and lines > 1:
|
|
375
|
+
changes["wrapped_lines"] = lines
|
|
376
|
+
if modest:
|
|
377
|
+
warnings.append(_ROW_GROWTH_NOTE)
|
|
378
|
+
return FitResult(
|
|
379
|
+
ok=True,
|
|
380
|
+
value=value,
|
|
381
|
+
applied_value=value,
|
|
382
|
+
applied_style_changes=changes,
|
|
383
|
+
lines=lines,
|
|
384
|
+
font_pt=round(candidate, 2),
|
|
385
|
+
confidence="high",
|
|
386
|
+
overflow_detected=modest,
|
|
387
|
+
warnings=warnings,
|
|
388
|
+
field_id=field_id,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _overflow_height(
|
|
392
|
+
self,
|
|
393
|
+
value: str,
|
|
394
|
+
slot: SlotMetrics,
|
|
395
|
+
policy: FitPolicy,
|
|
396
|
+
field_id: str | None,
|
|
397
|
+
wrapped_lines: int,
|
|
398
|
+
font_pt: float,
|
|
399
|
+
) -> FitResult:
|
|
400
|
+
"""Terminal action for a gross vertical balloon that shrink cannot resolve."""
|
|
401
|
+
|
|
402
|
+
budget = slot.height_lines(font_pt)
|
|
403
|
+
action = policy.overflow
|
|
404
|
+
# Line-level truncation is not modelled; keep the value and warn (honest).
|
|
405
|
+
if action == "truncate":
|
|
406
|
+
action = "warn"
|
|
407
|
+
if action == "warn":
|
|
408
|
+
return FitResult(
|
|
409
|
+
ok=True,
|
|
410
|
+
value=value,
|
|
411
|
+
applied_value=value,
|
|
412
|
+
lines=wrapped_lines,
|
|
413
|
+
font_pt=font_pt,
|
|
414
|
+
overflow_detected=True,
|
|
415
|
+
confidence="high",
|
|
416
|
+
warnings=[
|
|
417
|
+
f"value needs ~{wrapped_lines} lines but the row height holds "
|
|
418
|
+
f"~{budget}; the row will balloon (overflow=warn)"
|
|
419
|
+
],
|
|
420
|
+
field_id=field_id,
|
|
421
|
+
)
|
|
422
|
+
return FitResult(
|
|
423
|
+
ok=False,
|
|
424
|
+
value=value,
|
|
425
|
+
applied_value=value,
|
|
426
|
+
lines=wrapped_lines,
|
|
427
|
+
font_pt=font_pt,
|
|
428
|
+
overflow_detected=True,
|
|
429
|
+
confidence="high",
|
|
430
|
+
errors=[
|
|
431
|
+
f"FIELD_OVERFLOW: value needs ~{wrapped_lines} lines but the row height "
|
|
432
|
+
f"holds ~{budget}; shrink/shorten the value or allow row expansion"
|
|
433
|
+
],
|
|
434
|
+
field_id=field_id,
|
|
435
|
+
)
|
|
436
|
+
|
|
215
437
|
# -- terminal overflow action ----------------------------------------- #
|
|
216
438
|
def _overflow(
|
|
217
439
|
self,
|
hwpx/form_fit/measure.py
CHANGED
|
@@ -53,6 +53,31 @@ _CLASS_UNCERTAINTY: dict[str, float] = {
|
|
|
53
53
|
# multiplicative factor so it scales and also buys headroom on the advance error.
|
|
54
54
|
DEFAULT_SAFETY = 0.93
|
|
55
55
|
|
|
56
|
+
# --- Vertical (line-height) model ------------------------------------------- #
|
|
57
|
+
# Per-line vertical advance as a multiple of the em (font height in HWPUNIT).
|
|
58
|
+
# HWP's application default is 160% line spacing, so a line occupies ~1.6 em; this
|
|
59
|
+
# is the advance used when a paragraph declares no explicit lineSpacing, and it
|
|
60
|
+
# matches ``layout.lint._LINE_SPACING``. Used as the *expected* pitch for the
|
|
61
|
+
# authored vertical budget (the point at which the row is warned it will grow).
|
|
62
|
+
DEFAULT_LINE_SPACING_RATIO = 1.6
|
|
63
|
+
|
|
64
|
+
# Tightest per-line advance Hancom is observed to use (a glyph box with no leading).
|
|
65
|
+
# Measured on the M9 wild-form corpus: two soft-wrapped lines render inside cells
|
|
66
|
+
# only ~2.0 em tall, so the real pitch bottoms out near 1.0 em. Used to compute the
|
|
67
|
+
# *optimistic* (most generous) height budget, so a vertical overflow is only treated
|
|
68
|
+
# as high confidence when content overflows even at this tightest pitch.
|
|
69
|
+
MIN_LINE_SPACING_RATIO = 1.0
|
|
70
|
+
|
|
71
|
+
# A vertical overflow is a *hard* balloon (shrink-or-refuse) only when content needs
|
|
72
|
+
# at least this multiple of the optimistic authored line budget AND this many extra
|
|
73
|
+
# lines in absolute terms. Below the gross threshold the row may grow only modestly
|
|
74
|
+
# and the render oracle arbitrates. Measure-first calibration on the M9 corpus: a
|
|
75
|
+
# hard cap at the optimistic budget mis-refuses 47% of known-good multi-line cells,
|
|
76
|
+
# whereas this gross gate mis-refuses 2% — it fires on page-shifting balloons only.
|
|
77
|
+
# (Same balloon philosophy as ``layout.lint._BALLOON_FACTOR``.)
|
|
78
|
+
GROSS_ROW_GROWTH_FACTOR = 2.0
|
|
79
|
+
MIN_ROW_GROWTH_LINES = 2
|
|
80
|
+
|
|
56
81
|
Confidence = Literal["high", "low"]
|
|
57
82
|
|
|
58
83
|
|
|
@@ -189,11 +214,62 @@ class SlotMetrics:
|
|
|
189
214
|
max_lines: int = 1
|
|
190
215
|
raw_width: float | None = None # cellSz.width before margins (diagnostics)
|
|
191
216
|
source: str = "cell"
|
|
217
|
+
# Vertical budget. ``available_height`` is the usable inner height (HWPUNIT)
|
|
218
|
+
# after top/bottom cell margins + the safety inset. ``None`` means the vertical
|
|
219
|
+
# room was not (or could not be) measured, and the fit stays width-only.
|
|
220
|
+
available_height: float | None = None
|
|
221
|
+
# Per-line advance as a multiple of the em, from the cell's declared paragraph
|
|
222
|
+
# line spacing (PERCENT). ``None`` falls back to ``DEFAULT_LINE_SPACING_RATIO``.
|
|
223
|
+
line_spacing_ratio: float | None = None
|
|
224
|
+
# A cell height existed but was unusable (merged row-span fragment, or an
|
|
225
|
+
# auto-grow floor shorter than one line). Records "height budget unavailable"
|
|
226
|
+
# so the fit reports width-only honestly rather than guessing a vertical fit.
|
|
227
|
+
height_unavailable: bool = False
|
|
192
228
|
|
|
193
229
|
@property
|
|
194
230
|
def capacity(self) -> float:
|
|
195
231
|
return self.available_width * self.max_lines
|
|
196
232
|
|
|
233
|
+
def _line_ratio(self) -> float:
|
|
234
|
+
ratio = self.line_spacing_ratio
|
|
235
|
+
return ratio if ratio and ratio > 0 else DEFAULT_LINE_SPACING_RATIO
|
|
236
|
+
|
|
237
|
+
def line_height(self, font_pt: float | None = None) -> float:
|
|
238
|
+
"""Expected per-line vertical advance in HWPUNIT at *font_pt*."""
|
|
239
|
+
|
|
240
|
+
pt = self.font_pt if font_pt is None else font_pt
|
|
241
|
+
return pt * 100.0 * self._line_ratio()
|
|
242
|
+
|
|
243
|
+
def height_lines(self, font_pt: float | None = None) -> int | None:
|
|
244
|
+
"""Expected vertical line budget at *font_pt* (declared/default pitch).
|
|
245
|
+
|
|
246
|
+
``None`` when the vertical room is unmeasured. Never less than 1 — a cell
|
|
247
|
+
always accommodates its first line; the risk we guard is *growth* past it.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
if self.available_height is None:
|
|
251
|
+
return None
|
|
252
|
+
line_h = self.line_height(font_pt)
|
|
253
|
+
if line_h <= 0:
|
|
254
|
+
return None
|
|
255
|
+
return max(int(self.available_height // line_h), 1)
|
|
256
|
+
|
|
257
|
+
def height_lines_optimistic(self, font_pt: float | None = None) -> int | None:
|
|
258
|
+
"""Most-generous vertical budget (tightest plausible pitch).
|
|
259
|
+
|
|
260
|
+
This is the basis for the *confidence* of a vertical overflow: content that
|
|
261
|
+
overflows even this budget is grossly too tall regardless of pitch error.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
if self.available_height is None:
|
|
265
|
+
return None
|
|
266
|
+
pt = self.font_pt if font_pt is None else font_pt
|
|
267
|
+
ratio = min(self._line_ratio(), MIN_LINE_SPACING_RATIO)
|
|
268
|
+
line_h = pt * 100.0 * ratio
|
|
269
|
+
if line_h <= 0:
|
|
270
|
+
return None
|
|
271
|
+
return max(int(self.available_height // line_h), 1)
|
|
272
|
+
|
|
197
273
|
|
|
198
274
|
@dataclass(slots=True)
|
|
199
275
|
class Measurement:
|
|
@@ -291,6 +367,50 @@ def _cell_margin(cell_element: object) -> tuple[int, int]:
|
|
|
291
367
|
return (0, 0)
|
|
292
368
|
|
|
293
369
|
|
|
370
|
+
def _cell_margin_vertical(cell_element: object) -> tuple[int, int]:
|
|
371
|
+
"""Return (top, bottom) cellMargin in HWPUNIT, defaulting to 0."""
|
|
372
|
+
|
|
373
|
+
for child in cell_element: # type: ignore[attr-defined]
|
|
374
|
+
if _local_name(child.tag) == "cellMargin":
|
|
375
|
+
return (
|
|
376
|
+
int(child.get("top", "0") or 0),
|
|
377
|
+
int(child.get("bottom", "0") or 0),
|
|
378
|
+
)
|
|
379
|
+
return (0, 0)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _first_para_line_spacing_ratio(cell: object, document: object) -> float | None:
|
|
383
|
+
"""Per-line em multiple from the cell's first paragraph line spacing (PERCENT).
|
|
384
|
+
|
|
385
|
+
Only PERCENT spacing maps cleanly onto the em-relative line-height model; FIXED
|
|
386
|
+
/ ATLEAST / BETWEENLINES are left to the conservative default so we never invent
|
|
387
|
+
a font-independent pitch (the shrink ladder varies the font).
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
paragraphs = cell.paragraphs # type: ignore[attr-defined]
|
|
392
|
+
except Exception: # pragma: no cover - defensive
|
|
393
|
+
return None
|
|
394
|
+
for paragraph in paragraphs:
|
|
395
|
+
ref = getattr(paragraph, "para_pr_id_ref", None)
|
|
396
|
+
if ref is None or document is None:
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
prop = document.paragraph_property(ref) # type: ignore[attr-defined]
|
|
400
|
+
except Exception: # pragma: no cover - defensive
|
|
401
|
+
prop = None
|
|
402
|
+
spacing = getattr(prop, "line_spacing", None) if prop is not None else None
|
|
403
|
+
if spacing is None or not getattr(spacing, "value", None):
|
|
404
|
+
continue
|
|
405
|
+
if (getattr(spacing, "spacing_type", None) or "PERCENT").upper() != "PERCENT":
|
|
406
|
+
return None
|
|
407
|
+
try:
|
|
408
|
+
return int(spacing.value) / 100.0
|
|
409
|
+
except (TypeError, ValueError): # pragma: no cover - defensive
|
|
410
|
+
return None
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
294
414
|
def _first_run_font_pt(cell: object, document: object) -> float:
|
|
295
415
|
"""Resolve the cell's first run font size in points (default 10pt)."""
|
|
296
416
|
|
|
@@ -339,6 +459,14 @@ def resolve_slot_metrics(
|
|
|
339
459
|
verified against Hancom's own ``lineSeg/@horzsize`` (±10 HWPUNIT on 82% of
|
|
340
460
|
cells; the safety factor covers the rest plus paragraph indent, which is left
|
|
341
461
|
to the HarfBuzz pass).
|
|
462
|
+
|
|
463
|
+
``available_height`` follows the same philosophy — ``(cellSz.height - top -
|
|
464
|
+
bottom margin) * safety`` — but is recorded as *unavailable* (``None`` +
|
|
465
|
+
``height_unavailable``) whenever the authored height is not a real ceiling:
|
|
466
|
+
a merged cell (its ``cellSz.height`` is a single-row fragment, not the spanned
|
|
467
|
+
height), a cell with no height, or an auto-grow floor shorter than one line
|
|
468
|
+
(Hancom simply grows the row past it). The fit then stays width-only rather
|
|
469
|
+
than guess a vertical fit.
|
|
342
470
|
"""
|
|
343
471
|
|
|
344
472
|
raw_width = float(getattr(cell, "width", 0) or 0)
|
|
@@ -346,12 +474,35 @@ def resolve_slot_metrics(
|
|
|
346
474
|
left, right = _cell_margin(element) if element is not None else (0, 0)
|
|
347
475
|
inner = max(raw_width - left - right, 0.0) * safety
|
|
348
476
|
resolved_pt = font_pt if font_pt is not None else _first_run_font_pt(cell, document)
|
|
477
|
+
line_ratio = _first_para_line_spacing_ratio(cell, document)
|
|
478
|
+
|
|
479
|
+
available_height: float | None = None
|
|
480
|
+
height_unavailable = False
|
|
481
|
+
raw_height = float(getattr(cell, "height", 0) or 0)
|
|
482
|
+
try:
|
|
483
|
+
row_span = int(getattr(cell, "span", (1, 1))[0])
|
|
484
|
+
except Exception: # pragma: no cover - defensive
|
|
485
|
+
row_span = 1
|
|
486
|
+
top, bottom = _cell_margin_vertical(element) if element is not None else (0, 0)
|
|
487
|
+
inner_h = max(raw_height - top - bottom, 0.0) * safety if raw_height > 0 else 0.0
|
|
488
|
+
# A cell authored shorter than one line at the tightest pitch is an auto-grow
|
|
489
|
+
# floor, not a ceiling (Hancom grows the row past it); a merged row-span's
|
|
490
|
+
# cellSz.height is only one of the spanned rows. Neither is a usable budget.
|
|
491
|
+
one_line = resolved_pt * 100.0 * MIN_LINE_SPACING_RATIO
|
|
492
|
+
if row_span <= 1 and inner_h >= one_line:
|
|
493
|
+
available_height = inner_h
|
|
494
|
+
else:
|
|
495
|
+
height_unavailable = True
|
|
496
|
+
|
|
349
497
|
return SlotMetrics(
|
|
350
498
|
available_width=inner,
|
|
351
499
|
font_pt=resolved_pt,
|
|
352
500
|
max_lines=max(max_lines, 1),
|
|
353
501
|
raw_width=raw_width,
|
|
354
502
|
source="cell",
|
|
503
|
+
available_height=available_height,
|
|
504
|
+
line_spacing_ratio=line_ratio,
|
|
505
|
+
height_unavailable=height_unavailable,
|
|
355
506
|
)
|
|
356
507
|
|
|
357
508
|
|
|
@@ -360,6 +511,10 @@ __all__ = [
|
|
|
360
511
|
"Measurement",
|
|
361
512
|
"Confidence",
|
|
362
513
|
"DEFAULT_SAFETY",
|
|
514
|
+
"DEFAULT_LINE_SPACING_RATIO",
|
|
515
|
+
"MIN_LINE_SPACING_RATIO",
|
|
516
|
+
"GROSS_ROW_GROWTH_FACTOR",
|
|
517
|
+
"MIN_ROW_GROWTH_LINES",
|
|
363
518
|
"classify_char",
|
|
364
519
|
"char_advance",
|
|
365
520
|
"estimate_text_width",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.0
|
|
4
4
|
Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -70,7 +70,7 @@ Dynamic: license-file
|
|
|
70
70
|
`hwpx-mcp-server`와 `hwpx-plugin`은 같은 프로젝트가 직접 유지보수하는 first-party 연동 구성요소입니다.
|
|
71
71
|
“first-party”는 프로젝트 유지보수 관계를 뜻하며, 한컴 또는 제3자의 공식 인증을 뜻하지 않습니다.
|
|
72
72
|
|
|
73
|
-
현재 PyPI 공개 릴리스는 `python-hwpx 3.
|
|
73
|
+
현재 PyPI 공개 릴리스는 `python-hwpx 3.5.0`입니다. 일반
|
|
74
74
|
`pip install python-hwpx`로 이 릴리스를 설치할 수 있습니다.
|
|
75
75
|
현재 패키지 분류는 `Development Status :: 3 - Alpha`입니다. 이 분류는 API와 제품의
|
|
76
76
|
성숙도를 나타내며, 공개 버전이나 플러그인의 최소 호환 버전을 대신하지 않습니다.
|
|
@@ -94,8 +94,8 @@ hwpx/exam/parser.py,sha256=hA_oq_8i1XpmzJ8xw1L7R6LenxREKRvlOwMMXbs9Mpc,5026
|
|
|
94
94
|
hwpx/exam/profile.py,sha256=pEYpgwoNUuMToHMJjiknHykjXs0UobGuYW67N_3VnEQ,4071
|
|
95
95
|
hwpx/form_fit/__init__.py,sha256=l1HcFge4u_wL2vogB-VxSMrb-8h7zUT_IgTWMtnH9CM,1779
|
|
96
96
|
hwpx/form_fit/apply.py,sha256=X2q7K-mLku4AXxm4-xP80QGzhBlpzpqA_Ade8aPXzNY,3450
|
|
97
|
-
hwpx/form_fit/engine.py,sha256=
|
|
98
|
-
hwpx/form_fit/measure.py,sha256=
|
|
97
|
+
hwpx/form_fit/engine.py,sha256=N6twcIS-xjVdkCtVy03cHG8dGtRDtYV7pTRMhsPQ8rc,19799
|
|
98
|
+
hwpx/form_fit/measure.py,sha256=HZfcsCrvv4GOPIp8Tiz7BHpw_Hv0LGWrpkpV19PKFPg,20221
|
|
99
99
|
hwpx/form_fit/policy.py,sha256=iXAatjS7ZycOdKgMCL4Q_kYg0qnogFPdRNlr_ecOvjc,3106
|
|
100
100
|
hwpx/form_fit/report.py,sha256=h1NzQfbj1JYvZtKUi0DckJNWGnUHMZtQ3iCzbUxUjHA,3305
|
|
101
101
|
hwpx/form_fit/seal.py,sha256=QsPzFagaEi7UKAXS-iFwiZs8rLG-_Fo9RixLc2OaqbU,17640
|
|
@@ -199,10 +199,10 @@ hwpx/visual/page_qa.py,sha256=AuJCySfLIdXjPeekhAc3YnrIWOsN0sI35Pnz8BZ6Pro,7278
|
|
|
199
199
|
hwpx/visual/qa_contracts.py,sha256=1cjoiRTAWgi7NgE8SPc6bKxdoUfKx9nyou1mgC47TxY,10506
|
|
200
200
|
hwpx/visual/qa_metrics.py,sha256=I7RdybN-f1Fvcv2j-ceDPoek51nOkMui1Zrd7TEX_C8,9838
|
|
201
201
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
202
|
-
python_hwpx-3.
|
|
203
|
-
python_hwpx-3.
|
|
204
|
-
python_hwpx-3.
|
|
205
|
-
python_hwpx-3.
|
|
206
|
-
python_hwpx-3.
|
|
207
|
-
python_hwpx-3.
|
|
208
|
-
python_hwpx-3.
|
|
202
|
+
python_hwpx-3.5.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
203
|
+
python_hwpx-3.5.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
204
|
+
python_hwpx-3.5.0.dist-info/METADATA,sha256=dokrEArymZrHXyslBq1OhwQwYg3NhnNSeFLpHx10ppk,13561
|
|
205
|
+
python_hwpx-3.5.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
206
|
+
python_hwpx-3.5.0.dist-info/entry_points.txt,sha256=PVUBTBQtBHiflQ9XBjfd004w-LrDibgZjwzxgu8Tx7w,480
|
|
207
|
+
python_hwpx-3.5.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
208
|
+
python_hwpx-3.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|