Skip to content

Commit 963813d

Browse files
author
Gib hier deinen Namen ein
committed
Hybrid quality checks (heuristics warn-only) + thresholds
1 parent 03cacb4 commit 963813d

5 files changed

Lines changed: 252 additions & 2 deletions

File tree

packages/quality_check/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
from .preflight_checker import run_preflight
55
from .amazon_checker import check_amazon_constraints
66
from .policy import evaluate_quality_gate, summarize_quality_gate
7+
from .hybrid_checks import HeuristicConfig, run_heuristic_checks
78

89
__all__ = [
910
"validate_layout_semantics",
1011
"run_preflight",
1112
"check_amazon_constraints",
1213
"evaluate_quality_gate",
1314
"summarize_quality_gate",
15+
"run_heuristic_checks",
16+
"HeuristicConfig",
1417
]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any, Dict, List, Optional, Tuple
5+
6+
7+
@dataclass(frozen=True)
8+
class HeuristicConfig:
9+
# Text overflow heuristic
10+
avg_char_width_em: float = 0.6 # ~0.5-0.6 is typical for Latin fonts
11+
line_height_em: float = 1.2
12+
overflow_warn_ratio: float = 1.0 # >1 means text likely doesn't fit
13+
overflow_info_ratio: float = 0.9 # >0.9 means tight fit
14+
15+
# Page density heuristic
16+
objects_per_page_warn: int = 80
17+
18+
19+
def _as_float(v: Any, default: float = 0.0) -> float:
20+
try:
21+
return float(v)
22+
except Exception:
23+
return float(default)
24+
25+
26+
def _as_int(v: Any, default: int = 0) -> int:
27+
try:
28+
return int(v)
29+
except Exception:
30+
return int(default)
31+
32+
33+
def _iter_pages(layout_json: Dict[str, Any]):
34+
for page in layout_json.get("pages", []) or []:
35+
if isinstance(page, dict):
36+
yield page
37+
38+
39+
def _iter_objects(page: Dict[str, Any]):
40+
for obj in page.get("objects", []) or []:
41+
if isinstance(obj, dict):
42+
yield obj
43+
44+
45+
def estimate_text_overflow_ratio(
46+
*,
47+
text: str,
48+
bbox_w: float,
49+
bbox_h: float,
50+
font_size: float,
51+
cfg: HeuristicConfig,
52+
) -> Optional[float]:
53+
"""
54+
Estimate overflow risk as (needed_height / available_height).
55+
Returns None if not enough inputs.
56+
"""
57+
58+
if not text or bbox_w <= 0 or bbox_h <= 0 or font_size <= 0:
59+
return None
60+
61+
# chars per line (rough)
62+
char_w = max(0.1, cfg.avg_char_width_em * font_size)
63+
cpl = max(1.0, bbox_w / char_w)
64+
65+
# count "visual" characters (ignore excessive whitespace)
66+
compact = " ".join(text.split())
67+
n = len(compact)
68+
if n <= 0:
69+
return None
70+
71+
# line count (simple)
72+
lines = max(1.0, (n / cpl))
73+
line_h = max(0.1, cfg.line_height_em * font_size)
74+
needed_h = lines * line_h
75+
76+
return needed_h / bbox_h
77+
78+
79+
def run_heuristic_checks(
80+
layout_json: Dict[str, Any],
81+
*,
82+
config: Optional[HeuristicConfig] = None,
83+
) -> Dict[str, Any]:
84+
"""
85+
Warn-only checks:
86+
- text_overflow_risk: estimates if text likely doesn't fit its bbox
87+
- page_density: objects-per-page beyond threshold
88+
89+
Output is designed to be stable/deterministic given identical inputs.
90+
"""
91+
92+
cfg = config or HeuristicConfig()
93+
94+
warnings: List[Dict[str, Any]] = []
95+
infos: List[Dict[str, Any]] = []
96+
97+
pages_summary: List[Dict[str, Any]] = []
98+
for page in _iter_pages(layout_json):
99+
pn = _as_int(page.get("pageNumber"), 0)
100+
objs = list(_iter_objects(page))
101+
pages_summary.append({"pageNumber": pn, "objectCount": len(objs)})
102+
103+
if len(objs) >= cfg.objects_per_page_warn:
104+
warnings.append(
105+
{
106+
"id": "page.density",
107+
"page": pn,
108+
"message": f"High object count on page {pn}: {len(objs)} objects",
109+
"objectCount": len(objs),
110+
}
111+
)
112+
113+
for obj in objs:
114+
if str(obj.get("type") or "") != "text":
115+
continue
116+
text = str(obj.get("content") or "")
117+
bbox = obj.get("bbox") or {}
118+
ratio = estimate_text_overflow_ratio(
119+
text=text,
120+
bbox_w=_as_float(bbox.get("w"), 0.0),
121+
bbox_h=_as_float(bbox.get("h"), 0.0),
122+
font_size=_as_float(obj.get("fontSize"), 0.0),
123+
cfg=cfg,
124+
)
125+
if ratio is None:
126+
continue
127+
128+
entry = {
129+
"id": "text.overflow_risk",
130+
"page": pn,
131+
"objectId": obj.get("id"),
132+
"ratio": round(float(ratio), 4),
133+
"message": "Estimated text overflow risk (needed_height / bbox_height)",
134+
}
135+
if ratio > cfg.overflow_warn_ratio:
136+
warnings.append(entry)
137+
elif ratio > cfg.overflow_info_ratio:
138+
infos.append(entry)
139+
140+
return {
141+
"passed": True, # warn-only module
142+
"warnings": warnings,
143+
"infos": infos,
144+
"summary": {
145+
"pages": pages_summary,
146+
"warnCount": len(warnings),
147+
"infoCount": len(infos),
148+
"config": {
149+
"avg_char_width_em": cfg.avg_char_width_em,
150+
"line_height_em": cfg.line_height_em,
151+
"overflow_warn_ratio": cfg.overflow_warn_ratio,
152+
"overflow_info_ratio": cfg.overflow_info_ratio,
153+
"objects_per_page_warn": cfg.objects_per_page_warn,
154+
},
155+
},
156+
}
157+

packages/workflow/step_executor.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,14 @@ def quality_check(
264264
- amazon: KDP constraint validation
265265
"""
266266

267-
from packages.quality_check import check_amazon_constraints, run_preflight, evaluate_quality_gate, summarize_quality_gate
267+
from packages.quality_check import (
268+
check_amazon_constraints,
269+
evaluate_quality_gate,
270+
run_heuristic_checks,
271+
run_preflight,
272+
summarize_quality_gate,
273+
HeuristicConfig,
274+
)
268275

269276
checks = checks or ["preflight", "amazon"]
270277
out_dir.mkdir(parents=True, exist_ok=True)
@@ -300,6 +307,18 @@ def quality_check(
300307
# Quality gate (fail/warn policy), always computed.
301308
gate_results = evaluate_quality_gate(layout, project_init=init)
302309
entry["quality_gate"] = summarize_quality_gate(gate_results)
310+
311+
# Heuristic checks (warn-only), opt-in via checks list.
312+
if "heuristics" in checks:
313+
hcfg_raw = ((init.get("quality") or {}).get("heuristics") or {})
314+
hcfg = HeuristicConfig(
315+
avg_char_width_em=float(hcfg_raw.get("avg_char_width_em", 0.6)),
316+
line_height_em=float(hcfg_raw.get("line_height_em", 1.2)),
317+
overflow_warn_ratio=float(hcfg_raw.get("overflow_warn_ratio", 1.0)),
318+
overflow_info_ratio=float(hcfg_raw.get("overflow_info_ratio", 0.9)),
319+
objects_per_page_warn=int(hcfg_raw.get("objects_per_page_warn", 80)),
320+
)
321+
entry["heuristics"] = run_heuristic_checks(layout, config=hcfg)
303322
except Exception as exc:
304323
report["errors"].append({"path": str(p), "error": str(exc)})
305324
continue

project_init.json.template

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@
3939
"enabled": false,
4040
"safety_margin_mm": 0.0
4141
},
42+
"quality": {
43+
"checks_default": ["preflight", "amazon", "heuristics"],
44+
"heuristics": {
45+
"avg_char_width_em": 0.6,
46+
"line_height_em": 1.2,
47+
"overflow_warn_ratio": 1.0,
48+
"overflow_info_ratio": 0.9,
49+
"objects_per_page_warn": 80
50+
}
51+
},
4252
"layout": {
4353
"grid": {"cols": 6},
4454
"layers": ["BACKGROUND", "IMAGES", "TEXT", "INFOBOX", "QUOTES", "OVERLAY"]
@@ -68,4 +78,3 @@
6878
"infographic_handling": "standard"
6979
}
7080
}
71-
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import json
2+
from pathlib import Path
3+
4+
from packages.workflow.step_executor import StepExecutor
5+
from packages.workflow.progress_tracker import ProgressTracker
6+
7+
8+
def _layout_with_overflow():
9+
# Small bbox + big text + large font => should trigger overflow warning
10+
return {
11+
"version": "1.0.0",
12+
"document": {"width": 1000, "height": 2000, "dpi": 300},
13+
"pages": [
14+
{
15+
"pageNumber": 1,
16+
"objects": [
17+
{
18+
"id": "t1",
19+
"type": "text",
20+
"bbox": {"x": 10, "y": 20, "w": 120, "h": 30},
21+
"layer": "TEXT",
22+
"content": "This is a long line of text that will not fit.",
23+
"fontFamily": "Inter",
24+
"fontSize": 28,
25+
}
26+
],
27+
}
28+
],
29+
}
30+
31+
32+
def test_quality_check_includes_heuristics_when_enabled(tmp_path: Path):
33+
layout_path = tmp_path / "layout.json"
34+
layout_path.write_text(json.dumps(_layout_with_overflow(), ensure_ascii=False, indent=2), encoding="utf-8")
35+
36+
project_init = tmp_path / "project_init.json"
37+
project_init.write_text(
38+
json.dumps(
39+
{
40+
"quality": {
41+
"heuristics": {
42+
"overflow_warn_ratio": 0.8,
43+
"objects_per_page_warn": 10,
44+
}
45+
}
46+
},
47+
ensure_ascii=False,
48+
indent=2,
49+
),
50+
encoding="utf-8",
51+
)
52+
53+
exec = StepExecutor(tracker=ProgressTracker(publish_to_bus=False))
54+
rep = exec.quality_check(layout_paths=[layout_path], out_dir=tmp_path / "qc", checks=["heuristics"], project_init=project_init)
55+
56+
assert rep["errors"] == []
57+
assert len(rep["outputs"]) == 1
58+
assert "heuristics" in rep["outputs"][0]
59+
h = rep["outputs"][0]["heuristics"]
60+
assert h["passed"] is True
61+
assert h["summary"]["warnCount"] >= 1
62+

0 commit comments

Comments
 (0)