python-hwpx 2.17.0__py3-none-any.whl → 2.19.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/tools/exporter.py CHANGED
@@ -14,6 +14,7 @@ from xml.etree import ElementTree as ET
14
14
  from zipfile import ZipFile
15
15
 
16
16
  from ..opc.security import guard_zip_file, parse_xml_stdlib
17
+ from .pii import PIIPolicy, mask_pii
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  from ..document import HwpxDocument
@@ -64,7 +65,18 @@ def _paragraph_text(p: ET.Element, *, tab_token: str = "\t") -> str:
64
65
  return "".join(parts)
65
66
 
66
67
 
67
- def _table_cells_text(tbl: ET.Element, *, tab_token: str = "\t") -> list[list[str]]:
68
+ def _mask_text(text: str, masking_policy: "PIIPolicy | None") -> str:
69
+ if masking_policy is None:
70
+ return text
71
+ return mask_pii(text, masking_policy)
72
+
73
+
74
+ def _table_cells_text(
75
+ tbl: ET.Element,
76
+ *,
77
+ tab_token: str = "\t",
78
+ masking_policy: "PIIPolicy | None" = None,
79
+ ) -> list[list[str]]:
68
80
  """Return a row-major 2D list of cell texts from a table element."""
69
81
  rows: list[list[str]] = []
70
82
  for tr in tbl.findall(f"{_HP}tr"):
@@ -75,7 +87,7 @@ def _table_cells_text(tbl: ET.Element, *, tab_token: str = "\t") -> list[list[st
75
87
  text = _paragraph_text(paragraph, tab_token=tab_token)
76
88
  if text:
77
89
  cell_parts.append(text)
78
- row.append("\n".join(cell_parts).strip())
90
+ row.append(_mask_text("\n".join(cell_parts).strip(), masking_policy))
79
91
  rows.append(row)
80
92
  return rows
81
93
 
@@ -84,19 +96,27 @@ def _find_tables(p: ET.Element) -> list[ET.Element]:
84
96
  return p.findall(f".//{_HP}tbl")
85
97
 
86
98
 
87
- def export_text(source: HwpxDocument | bytes, *, paragraph_separator: str = "\n", section_separator: str = "\n\n", include_tables: bool = True, tab_token: str = "\t") -> str:
99
+ def export_text(
100
+ source: HwpxDocument | bytes,
101
+ *,
102
+ paragraph_separator: str = "\n",
103
+ section_separator: str = "\n\n",
104
+ include_tables: bool = True,
105
+ tab_token: str = "\t",
106
+ masking_policy: "PIIPolicy | None" = None,
107
+ ) -> str:
88
108
  """Export document content as plain text."""
89
109
  sections = _section_xmls(source)
90
110
  section_texts: list[str] = []
91
111
  for section_root in sections:
92
112
  para_texts: list[str] = []
93
113
  for p in _iter_paragraphs(section_root):
94
- text = _paragraph_text(p, tab_token=tab_token)
114
+ text = _mask_text(_paragraph_text(p, tab_token=tab_token), masking_policy)
95
115
  if text:
96
116
  para_texts.append(text)
97
117
  if include_tables:
98
118
  for tbl in _find_tables(p):
99
- rows = _table_cells_text(tbl, tab_token=tab_token)
119
+ rows = _table_cells_text(tbl, tab_token=tab_token, masking_policy=masking_policy)
100
120
  for row in rows:
101
121
  para_texts.append(tab_token.join(row))
102
122
  section_texts.append(paragraph_separator.join(para_texts))
@@ -107,7 +127,15 @@ def _escape_html(text: str) -> str:
107
127
  return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
108
128
 
109
129
 
110
- def export_html(source: HwpxDocument | bytes, *, include_tables: bool = True, full_document: bool = True, title: str = "HWPX Document", tab_token: str = "\t") -> str:
130
+ def export_html(
131
+ source: HwpxDocument | bytes,
132
+ *,
133
+ include_tables: bool = True,
134
+ full_document: bool = True,
135
+ title: str = "HWPX Document",
136
+ tab_token: str = "\t",
137
+ masking_policy: "PIIPolicy | None" = None,
138
+ ) -> str:
111
139
  """Export document content as HTML."""
112
140
  sections = _section_xmls(source)
113
141
  body_parts: list[str] = []
@@ -115,12 +143,12 @@ def export_html(source: HwpxDocument | bytes, *, include_tables: bool = True, fu
115
143
  if sec_idx > 0:
116
144
  body_parts.append("<hr />")
117
145
  for p in _iter_paragraphs(section_root):
118
- text = _paragraph_text(p, tab_token=tab_token)
146
+ text = _mask_text(_paragraph_text(p, tab_token=tab_token), masking_policy)
119
147
  if text:
120
148
  body_parts.append(f"<p>{_escape_html(text)}</p>")
121
149
  if include_tables:
122
150
  for tbl in _find_tables(p):
123
- rows = _table_cells_text(tbl, tab_token=tab_token)
151
+ rows = _table_cells_text(tbl, tab_token=tab_token, masking_policy=masking_policy)
124
152
  if rows:
125
153
  body_parts.append('<table border="1">')
126
154
  for row in rows:
@@ -146,20 +174,27 @@ def export_html(source: HwpxDocument | bytes, *, include_tables: bool = True, fu
146
174
  return body
147
175
 
148
176
 
149
- def export_markdown(source: HwpxDocument | bytes, *, include_tables: bool = True, section_separator: str = "\n---\n\n", tab_token: str = "\t") -> str:
177
+ def export_markdown(
178
+ source: HwpxDocument | bytes,
179
+ *,
180
+ include_tables: bool = True,
181
+ section_separator: str = "\n---\n\n",
182
+ tab_token: str = "\t",
183
+ masking_policy: "PIIPolicy | None" = None,
184
+ ) -> str:
150
185
  """Export document content as Markdown."""
151
186
  sections = _section_xmls(source)
152
187
  section_parts: list[str] = []
153
188
  for section_root in sections:
154
189
  lines: list[str] = []
155
190
  for p in _iter_paragraphs(section_root):
156
- text = _paragraph_text(p, tab_token=tab_token)
191
+ text = _mask_text(_paragraph_text(p, tab_token=tab_token), masking_policy)
157
192
  if text:
158
193
  lines.append(text)
159
194
  lines.append("")
160
195
  if include_tables:
161
196
  for tbl in _find_tables(p):
162
- rows = _table_cells_text(tbl, tab_token=tab_token)
197
+ rows = _table_cells_text(tbl, tab_token=tab_token, masking_policy=masking_policy)
163
198
  if rows:
164
199
  header = rows[0]
165
200
  lines.append("| " + " | ".join(header) + " |")
hwpx/tools/mail_merge.py CHANGED
@@ -14,6 +14,7 @@ from zipfile import ZIP_DEFLATED, ZipFile
14
14
  from ..document import HwpxDocument
15
15
  from .exporter import export_text
16
16
  from .package_validator import validate_editor_open_safety
17
+ from .pii import DEFAULT_POLICY, PIIPolicy, mask_pii
17
18
 
18
19
  MAIL_MERGE_REPORT_VERSION = "mail-merge-v1"
19
20
 
@@ -180,6 +181,7 @@ def mail_merge(
180
181
  split_newlines: bool = True,
181
182
  fit_policy: "Any | None" = None,
182
183
  max_lines: int = 1,
184
+ masking_policy: "PIIPolicy | None" = DEFAULT_POLICY,
183
185
  ) -> dict[str, Any]:
184
186
  """Generate one HWPX per row from a placeholder template.
185
187
 
@@ -237,6 +239,7 @@ def mail_merge(
237
239
  "unresolvedPlaceholders": [item["token"] for item in placeholders if item["key"] in missing_keys],
238
240
  "openSafety": None,
239
241
  "fitFields": [],
242
+ "maskedFields": [],
240
243
  "reasons": ["missing_required"],
241
244
  "ok": False,
242
245
  }
@@ -247,11 +250,17 @@ def mail_merge(
247
250
  row_cells = _iter_cells(document)
248
251
  replaced_count = 0
249
252
  fit_fields: list[dict[str, Any]] = []
253
+ masked_fields: list[str] = []
250
254
  try:
251
255
  for placeholder in placeholders:
252
256
  key = str(placeholder["key"])
253
257
  token = str(placeholder["token"])
254
258
  value = "" if _is_missing(row.get(key)) else str(row.get(key))
259
+ if masking_policy is not None:
260
+ masked_value = mask_pii(value, masking_policy)
261
+ if masked_value != value and key not in masked_fields:
262
+ masked_fields.append(key)
263
+ value = masked_value
255
264
  if not split_newlines:
256
265
  value = value.replace("\r\n", " ").replace("\n", " ")
257
266
  apply_value = value
@@ -287,6 +296,7 @@ def mail_merge(
287
296
  "unresolvedPlaceholders": unresolved,
288
297
  "openSafety": open_safety,
289
298
  "fitFields": fit_fields,
299
+ "maskedFields": masked_fields,
290
300
  "reasons": reasons,
291
301
  "ok": row_ok,
292
302
  }
hwpx/tools/pii.py ADDED
@@ -0,0 +1,389 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """PII detection, masking, and structural privacy helpers.
3
+
4
+ The machine-checkable set is intentionally always enabled by default. Contextual
5
+ patterns are label-gated and reported as low-confidence to avoid broad free-text
6
+ over-masking.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import logging
12
+ import re
13
+ from collections.abc import Iterable, Mapping, Sized
14
+ from dataclasses import dataclass
15
+ from typing import Any, Literal, TypedDict
16
+
17
+
18
+ Confidence = Literal["high", "low"]
19
+
20
+
21
+ class PIISpan(TypedDict):
22
+ type: str
23
+ start: int
24
+ end: int
25
+ value: str
26
+ confidence: Confidence
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PIIPolicy:
31
+ """Controls PII detection and masking behavior."""
32
+
33
+ mask_char: str = "*"
34
+ machine_enabled: bool = True
35
+ contextual_enabled: bool = True
36
+ prefix_len: int = 1
37
+ suffix_len: int = 0
38
+
39
+ def __post_init__(self) -> None:
40
+ if len(self.mask_char) != 1:
41
+ raise ValueError("mask_char must be exactly one character")
42
+ if self.prefix_len < 0 or self.suffix_len < 0:
43
+ raise ValueError("prefix_len and suffix_len must be non-negative")
44
+
45
+
46
+ DEFAULT_POLICY = PIIPolicy()
47
+
48
+ __all__ = [
49
+ "Confidence",
50
+ "DEFAULT_POLICY",
51
+ "PIIPolicy",
52
+ "PIISpan",
53
+ "PiiLogFilter",
54
+ "Pseudonymizer",
55
+ "deidentify",
56
+ "detect_pii",
57
+ "mask_pii",
58
+ "mask_value",
59
+ "minimize_fields",
60
+ "scrub_exception_message",
61
+ ]
62
+
63
+ _RRN_RE = re.compile(r"(?<!\d)(?P<front>\d{6})-?(?P<gender>[1-4])(?P<rear>\d{6})(?!\d)")
64
+ _PHONE_RE = re.compile(
65
+ r"(?<!\d)(?P<prefix>010|011|016|017|018|019)-?(?P<middle>\d{3,4})-?(?P<last>\d{4})(?!\d)"
66
+ )
67
+ _EMAIL_RE = re.compile(
68
+ r"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])"
69
+ r"(?P<local>[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+)"
70
+ r"@"
71
+ r"(?P<domain>[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
72
+ r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)"
73
+ r"(?![A-Za-z0-9-])"
74
+ )
75
+ _CARD_RE = re.compile(r"(?<!\d)(?:\d{4}[- ]?){3}\d{4}(?!\d)")
76
+
77
+ _ACCOUNT_RE = re.compile(
78
+ r"(?:계좌(?:번호)?|통장(?:번호)?)[^\d\n]{0,12}(?P<value>\d[\d-]{8,20}\d)"
79
+ )
80
+ _REGIONS = (
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
+ _REGION_RE = "|".join(re.escape(region) for region in _REGIONS)
117
+ _ADDRESS_RE = re.compile(
118
+ rf"(?:주소|도로명주소|소재지|거주지)\s*[::]?\s*(?P<value>(?:{_REGION_RE})[^\n,;]{{2,80}})"
119
+ )
120
+ _NAME_RE = re.compile(r"(?:성명|수신자|이름)\s*[::]\s*(?P<value>[가-힣]{2,4})")
121
+
122
+ _TYPE_ALIASES = {
123
+ "resident_registration_number": "rrn",
124
+ "registration_number": "rrn",
125
+ "jumin": "rrn",
126
+ "주민등록번호": "rrn",
127
+ "휴대폰": "phone",
128
+ "전화번호": "phone",
129
+ "mobile": "phone",
130
+ "email_address": "email",
131
+ "이메일": "email",
132
+ "card_number": "card",
133
+ "credit_card": "card",
134
+ "카드": "card",
135
+ "account_number": "account",
136
+ "계좌번호": "account",
137
+ "계좌": "account",
138
+ "주소": "address",
139
+ "이름": "name",
140
+ "성명": "name",
141
+ "수신자": "name",
142
+ }
143
+
144
+
145
+ def detect_pii(text: str, policy: PIIPolicy = DEFAULT_POLICY) -> list[PIISpan]:
146
+ """Detect PII spans in ``text`` according to ``policy``."""
147
+
148
+ spans: list[PIISpan] = []
149
+ if not text:
150
+ return spans
151
+
152
+ if policy.machine_enabled:
153
+ for match in _RRN_RE.finditer(text):
154
+ spans.append(_span("rrn", match.start(), match.end(), match.group(0), "high"))
155
+ for match in _PHONE_RE.finditer(text):
156
+ spans.append(_span("phone", match.start(), match.end(), match.group(0), "high"))
157
+ for match in _EMAIL_RE.finditer(text):
158
+ spans.append(_span("email", match.start(), match.end(), match.group(0), "high"))
159
+ for match in _CARD_RE.finditer(text):
160
+ value = match.group(0)
161
+ if _luhn_valid(_digits(value)):
162
+ spans.append(_span("card", match.start(), match.end(), value, "high"))
163
+
164
+ if policy.contextual_enabled:
165
+ for match in _ACCOUNT_RE.finditer(text):
166
+ value = match.group("value")
167
+ digits = _digits(value)
168
+ if 10 <= len(digits) <= 14:
169
+ spans.append(_span("account", match.start("value"), match.end("value"), value, "low"))
170
+ for match in _ADDRESS_RE.finditer(text):
171
+ value = match.group("value").rstrip()
172
+ start = match.start("value")
173
+ spans.append(_span("address", start, start + len(value), value, "low"))
174
+ for match in _NAME_RE.finditer(text):
175
+ value = match.group("value")
176
+ spans.append(_span("name", match.start("value"), match.end("value"), value, "low"))
177
+
178
+ return _without_overlaps(spans)
179
+
180
+
181
+ def mask_pii(text: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
182
+ """Return ``text`` with detected PII spans masked."""
183
+
184
+ masked = text
185
+ for span in sorted(detect_pii(text, policy), key=lambda item: item["start"], reverse=True):
186
+ start = span["start"]
187
+ end = span["end"]
188
+ masked = masked[:start] + mask_value(span["value"], span["type"], policy) + masked[end:]
189
+ return masked
190
+
191
+
192
+ def mask_value(value: str, pii_type: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
193
+ """Mask a single known PII value."""
194
+
195
+ normalized_type = _normalize_type(pii_type)
196
+ if normalized_type == "rrn":
197
+ match = _RRN_RE.fullmatch(value)
198
+ if match:
199
+ return f"{match.group('front')}-{match.group('gender')}{policy.mask_char * 6}"
200
+ digits = _digits(value)
201
+ if len(digits) == 13 and digits[6] in "1234":
202
+ return f"{digits[:6]}-{digits[6]}{policy.mask_char * 6}"
203
+ if normalized_type == "phone":
204
+ match = _PHONE_RE.fullmatch(value)
205
+ if match:
206
+ return f"{match.group('prefix')}-{policy.mask_char * 4}-{policy.mask_char * 4}"
207
+ if normalized_type == "email":
208
+ match = _EMAIL_RE.fullmatch(value)
209
+ if match:
210
+ return f"{match.group('local')[:1]}{policy.mask_char * 4}@{match.group('domain')}"
211
+ if normalized_type == "card":
212
+ digits = _digits(value)
213
+ if len(digits) == 16:
214
+ return f"{digits[:4]}-{policy.mask_char * 4}-{policy.mask_char * 4}-{digits[-4:]}"
215
+ if normalized_type == "account":
216
+ digits = _digits(value)
217
+ suffix_len = policy.suffix_len if policy.suffix_len > 0 else 4
218
+ if len(digits) > suffix_len:
219
+ return f"{policy.mask_char * 3}-{policy.mask_char * 4}-{digits[-suffix_len:]}"
220
+ if normalized_type == "name":
221
+ return _mask_generic(value, policy, suffix_len=0)
222
+ if normalized_type == "address":
223
+ return _mask_generic(value, policy, prefix_len=max(policy.prefix_len, 2), suffix_len=0)
224
+ return _mask_generic(value, policy)
225
+
226
+
227
+ def minimize_fields(
228
+ record: Mapping[str, Any],
229
+ allowed: Iterable[str],
230
+ *,
231
+ drop_empty: bool = False,
232
+ ) -> dict[str, Any]:
233
+ """Return a minimized copy containing only allowed fields.
234
+
235
+ Output order follows the order of ``allowed``. Missing fields are skipped.
236
+ When ``drop_empty`` is true, ``None`` and empty sized values are omitted
237
+ while falsey scalars such as ``0`` and ``False`` are retained.
238
+ """
239
+
240
+ minimized: dict[str, Any] = {}
241
+ for key in allowed:
242
+ if key not in record:
243
+ continue
244
+ value = record[key]
245
+ if drop_empty and _is_empty_value(value):
246
+ continue
247
+ minimized[key] = value
248
+ return minimized
249
+
250
+
251
+ class Pseudonymizer:
252
+ """Deterministic in-memory reversible pseudonym token map.
253
+
254
+ ``mapping()`` exposes the value-to-token map. Persisting that map outside
255
+ this instance makes the pseudonymization reversible; v1 keeps it in memory
256
+ only.
257
+ """
258
+
259
+ def __init__(self) -> None:
260
+ self._tokens: dict[str, str] = {}
261
+ self._counts: dict[str, int] = {}
262
+
263
+ def token(self, value: str, *, kind: str = "이름") -> str:
264
+ """Return a stable token for ``value`` within this instance."""
265
+
266
+ if value in self._tokens:
267
+ return self._tokens[value]
268
+ count = self._counts.get(kind, 0) + 1
269
+ self._counts[kind] = count
270
+ token = f"{kind}_{count:03d}"
271
+ self._tokens[value] = token
272
+ return token
273
+
274
+ def mapping(self) -> dict[str, str]:
275
+ """Return a copy of the in-memory value-to-token map."""
276
+
277
+ return dict(self._tokens)
278
+
279
+
280
+ def deidentify(value: str, *, salt: str, length: int = 12) -> str:
281
+ """Return an irreversible deterministic salted hash token."""
282
+
283
+ digest = hashlib.sha256((salt + "\x00" + value).encode()).hexdigest()
284
+ return "di_" + digest[:length]
285
+
286
+
287
+ class PiiLogFilter(logging.Filter):
288
+ """Logging filter that masks PII in formatted log messages."""
289
+
290
+ def __init__(self, name: str = "", policy: PIIPolicy = DEFAULT_POLICY) -> None:
291
+ super().__init__(name)
292
+ self.policy = policy
293
+
294
+ def filter(self, record: logging.LogRecord) -> bool:
295
+ record.msg = _scrub_log_value(record.msg, self.policy)
296
+ record.args = _scrub_log_value(record.args, self.policy)
297
+ try:
298
+ message = record.getMessage()
299
+ except Exception:
300
+ message = str(record.msg)
301
+ masked_message = mask_pii(message, self.policy)
302
+ record.msg = masked_message
303
+ record.args = ()
304
+ return True
305
+
306
+
307
+ def scrub_exception_message(msg: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
308
+ """Mask PII in an exception message before exposing or logging it."""
309
+
310
+ return mask_pii(msg, policy)
311
+
312
+
313
+ def _span(kind: str, start: int, end: int, value: str, confidence: Confidence) -> PIISpan:
314
+ return {"type": kind, "start": start, "end": end, "value": value, "confidence": confidence}
315
+
316
+
317
+ def _is_empty_value(value: Any) -> bool:
318
+ if value is None:
319
+ return True
320
+ return isinstance(value, Sized) and len(value) == 0
321
+
322
+
323
+ def _scrub_log_value(value: Any, policy: PIIPolicy) -> Any:
324
+ if isinstance(value, str):
325
+ return mask_pii(value, policy)
326
+ if isinstance(value, tuple):
327
+ return tuple(_scrub_log_value(item, policy) for item in value)
328
+ if isinstance(value, Mapping):
329
+ return {key: _scrub_log_value(item, policy) for key, item in value.items()}
330
+ return value
331
+
332
+
333
+ def _normalize_type(pii_type: str) -> str:
334
+ lowered = pii_type.strip().lower()
335
+ return _TYPE_ALIASES.get(lowered, lowered)
336
+
337
+
338
+ def _digits(value: str) -> str:
339
+ return "".join(ch for ch in value if ch.isdigit())
340
+
341
+
342
+ def _luhn_valid(digits: str) -> bool:
343
+ if len(digits) != 16 or not digits.isdigit():
344
+ return False
345
+ total = 0
346
+ double = False
347
+ for char in reversed(digits):
348
+ value = int(char)
349
+ if double:
350
+ value *= 2
351
+ if value > 9:
352
+ value -= 9
353
+ total += value
354
+ double = not double
355
+ return total % 10 == 0
356
+
357
+
358
+ def _without_overlaps(spans: list[PIISpan]) -> list[PIISpan]:
359
+ selected: list[PIISpan] = []
360
+ for span in sorted(spans, key=lambda item: (item["start"], -(item["end"] - item["start"]))):
361
+ if any(_overlaps(span, existing) for existing in selected):
362
+ continue
363
+ selected.append(span)
364
+ return sorted(selected, key=lambda item: (item["start"], item["end"]))
365
+
366
+
367
+ def _overlaps(left: PIISpan, right: PIISpan) -> bool:
368
+ return left["start"] < right["end"] and right["start"] < left["end"]
369
+
370
+
371
+ def _mask_generic(
372
+ value: str,
373
+ policy: PIIPolicy,
374
+ *,
375
+ prefix_len: int | None = None,
376
+ suffix_len: int | None = None,
377
+ ) -> str:
378
+ if not value:
379
+ return value
380
+ requested_prefix = policy.prefix_len if prefix_len is None else prefix_len
381
+ requested_suffix = policy.suffix_len if suffix_len is None else suffix_len
382
+ if len(value) <= requested_prefix + requested_suffix:
383
+ visible_prefix = min(requested_prefix, max(len(value) - 1, 0))
384
+ return f"{value[:visible_prefix]}{policy.mask_char * (len(value) - visible_prefix)}"
385
+ prefix = min(len(value), requested_prefix)
386
+ suffix = min(len(value) - prefix, requested_suffix)
387
+ mask_count = len(value) - prefix - suffix
388
+ suffix_text = value[-suffix:] if suffix else ""
389
+ return f"{value[:prefix]}{policy.mask_char * mask_count}{suffix_text}"