File size: 5,101 Bytes
f66643d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


@dataclass
class StyleSpec:
    weight: str = "regular"  # "regular" | "bold"
    style_: str = "normal"  # "normal" | "italic"
    align: str = "left"  # "left" | "center" | "right"


@dataclass
class SizingConfig:
    detect: bool = True
    cluster_eps_pt: float = 2.0
    # Maps group name → list of labels belonging to that group
    cluster_groups: dict[str, list[str]] = field(
        default_factory=lambda: {
            "body": [
                "Text",
                "ListItem",
                "Footnote",
                "Handwriting",
                "TextInlineMath",
            ],
            "toc": ["TableOfContents"],
            "equation": ["Equation"],
            "headings": ["SectionHeader"],
            "header_footer": ["PageHeader", "PageFooter"],
            "caption": ["Caption"],
        }
    )
    # "document" = cluster across all pages; "page" = cluster per page
    cluster_scope_by_group: dict[str, str] = field(
        default_factory=lambda: {
            "body": "page",
            "toc": "document",
            "equation": "page",
            "headings": "page",
            "header_footer": "page",
            "caption": "page",
        }
    )
    cap_height_ratio: float = 0.8
    fallback_size: float = 11.0
    # Used by _estimate_fit_size: avg char width / font_size and line-height / font_size
    char_width_ratio: float = 0.55
    leading_ratio: float = 1.25
    # Table cell tweaks: slightly smaller font + inset to avoid border overlap
    cell_font_scale: float = 0.88
    cell_bbox_inset_pt: float = 2.0


@dataclass
class BackgroundConfig:
    enabled: bool = True
    sample_margin_pt: float = 6.0
    dpi_scale: float = 2.0
    complexity_brightness_spread: float = 72.0
    text_contamination_dark_value: int = 220
    text_contamination_dark_ratio: float = 0.015
    min_sample_pixels: int = 24
    eraser_padding_pt: float = 1.5
    fallback_bg: tuple[int, int, int] = (255, 255, 255)


@dataclass
class TextColorConfig:
    enabled: bool = True
    center_fraction: float = 0.6
    fallback: tuple[int, int, int] = (0, 0, 0)


@dataclass
class CompressConfig:
    subset_fonts: bool = True
    deflate: bool = True
    pikepdf_image_recompress: bool = False
    target_dpi: int = 200
    jpeg_quality: int = 78


@dataclass
class RenderConfig:
    # Typst font configuration
    typst_font_paths: list[str] = field(default_factory=list)
    # Single name or fallback chain. Typst tries each in order when a glyph
    # is missing — useful for mixed-script content (Vietnamese, Greek, etc.).
    font_family: str | list[str] = "Helvetica"
    # Optional per-label style overrides
    styles: dict[str, StyleSpec] = field(
        default_factory=lambda: {
            "SectionHeader": StyleSpec(weight="bold"),
            "PageHeader": StyleSpec(align="center"),
            "PageFooter": StyleSpec(align="center"),
            "Caption": StyleSpec(style_="italic", align="center"),
            "TableOfContents": StyleSpec(),
        }
    )
    default_style: StyleSpec = field(default_factory=StyleSpec)
    cell_style: StyleSpec = field(default_factory=StyleSpec)
    sizing: SizingConfig = field(default_factory=SizingConfig)
    background: BackgroundConfig = field(default_factory=BackgroundConfig)
    text_color: TextColorConfig = field(default_factory=TextColorConfig)
    compress: CompressConfig = field(default_factory=CompressConfig)
    min_font_size_pt: float = 7.0
    expand_downward: bool = True
    max_expand_pt: float = 80.0
    # Remove native text layer in translatable regions (needed for non-scanned PDFs)
    redact_native_text: bool = True
    pages: list[int] | None = None
    typst_binary: str = "typst"
    keep_typst_source: bool = False

    # Legacy PyMuPDF fallback fields (kept for the fallback renderer)
    font_path: str = ""
    font_name: str = "Body"

    @classmethod
    def from_json(cls, path: str | Path) -> "RenderConfig":
        import json

        data = json.loads(Path(path).read_text(encoding="utf-8"))
        cfg = cls()
        if "font_family" in data:
            cfg.font_family = data["font_family"]
        if "typst_font_paths" in data:
            cfg.typst_font_paths = data["typst_font_paths"]
        if "typst_binary" in data:
            cfg.typst_binary = data["typst_binary"]
        if "font_path" in data:
            cfg.font_path = data["font_path"]
        if "min_font_size_pt" in data:
            cfg.min_font_size_pt = float(data["min_font_size_pt"])
        if "pages" in data:
            cfg.pages = data["pages"]
        _load_nested(cfg.sizing, data.get("sizing", {}))
        _load_nested(cfg.background, data.get("background", {}))
        _load_nested(cfg.text_color, data.get("text_color", {}))
        _load_nested(cfg.compress, data.get("compress", {}))
        return cfg


def _load_nested(obj: Any, d: dict) -> None:
    for k, v in d.items():
        if hasattr(obj, k):
            setattr(obj, k, v)