1- """SEG-Y textual file header validation and sanitization helpers.
2-
3- The SEG-Y standard defines the textual file header as a 3200-byte block
4- organized as 40 cards of 80 characters each, encoded as either ASCII or
5- EBCDIC. Both encodings used by ``TGSAI/segy`` ultimately require the
6- in-memory string to be 7-bit ASCII (``ord(c) <= 127``) before bytes can be
7- written. The MDIO on-disk representation is the wrapped form: 40 lines of
8- exactly 80 characters joined by ``"\\ n"``.
9-
10- When the source bytes were ingested through a lossy EBCDIC decode, MDIO
11- typically receives ``U+FFFD`` (``"\uFFFD "``) replacement characters and other
12- non-ASCII codepoints. Those characters round-trip through MDIO storage but
13- fail when ``segy.factory.create_textual_header`` tries to re-encode the
14- header to ASCII for SEG-Y export. The helpers in this module exist to detect
15- that situation up-front and, when requested, repair it deterministically.
16-
17- Repairs are conservative: any character that is either non-ASCII
18- (``ord(c) > 127``) or non-printable per :py:meth:`str.isprintable` is replaced
19- with an ASCII space, and the card grid is forced to exactly 40 rows of 80
20- columns. Newlines (``"\\ n"``) are treated only as row separators; other
21- Unicode line-break characters (``"\\ v"``, ``"\\ f"``, ``"\\ x85"``, ``"\u2028 "``,
22- ``"\u2029 "``) are treated as content and replaced rather than re-splitting
23- the layout. Sanitization additionally collapses runs of two or more
24- ``"\\ n"`` to one so headers that were written with ``"\\ n\\ n"`` between
25- cards are not silently truncated to half their length.
26- """
1+ """SEG-Y textual file header validation and sanitization helpers."""
272
283from __future__ import annotations
294
305import re
316
327EXPECTED_ROWS = 40
338EXPECTED_COLS = 80
34- EXPECTED_LENGTH = EXPECTED_ROWS * EXPECTED_COLS
359ASCII_MAX_ORD = 127
3610
3711_REPORT_LIMIT = 5
3812_NEWLINE_RUN = re .compile (r"\n{2,}" )
3913
4014
4115def _is_safe_char (char : str ) -> bool :
42- """Return True if a char is safe to round-trip through SEG-Y ASCII/EBCDIC.
43-
44- A char is "safe" when it is both 7-bit ASCII (``ord <= 127``) and printable
45- per :py:meth:`str.isprintable`. ASCII space passes; ``U+FFFD``, accented
46- Latin characters, control characters and tabs do not.
47- """
16+ """Return True if char is 7-bit ASCII and printable."""
4817 return ord (char ) <= ASCII_MAX_ORD and char .isprintable ()
4918
5019
51- def _split_rows (text_header : str ) -> list [str ]:
52- """Split a wrapped text header into rows on ``"\\ n"`` only.
53-
54- Other Unicode line-break characters (``"\\ v"``, ``"\\ f"``, ``"\u0085 "``, etc.)
55- are intentionally left in place so that lossy decodes do not silently
56- re-shape the card grid. They will surface as unsafe characters during
57- validation and be replaced during sanitization.
58- """
59- return text_header .split ("\n " )
60-
61-
62- def _find_unsafe (row : str ) -> list [int ]:
63- """Return positions of characters that are not :func:`_is_safe_char`."""
64- return [i for i , c in enumerate (row ) if not _is_safe_char (c )]
65-
66-
6720def _summarize (mapping : dict [int , list [int ]], limit : int = _REPORT_LIMIT ) -> str :
6821 """Format ``{row: [positions]}`` for an error message, capped for readability."""
6922 if not mapping :
@@ -80,18 +33,15 @@ def _summarize(mapping: dict[int, list[int]], limit: int = _REPORT_LIMIT) -> str
8033
8134
8235def validate_text_header (text_header : str ) -> None :
83- """Validate a SEG-Y textual file header is 40 rows of 80 ASCII-printable characters.
36+ r """Validate a SEG-Y textual file header is 40 rows of 80 ASCII-printable characters.
8437
8538 Args:
86- text_header: Decoded textual file header string in the wrapped form
87- (40 rows of 80 characters joined by ``"\\ n"``).
39+ text_header: Decoded text header in wrapped form (40 rows of 80 chars joined by ``\n``).
8840
8941 Raises:
90- ValueError: If the header does not split into exactly 40 rows on
91- ``"\\ n"``, any row is not 80 characters wide, or any character is
92- not safe to encode as 7-bit ASCII (see :func:`_is_safe_char`).
42+ ValueError: If row count, row width, or any character fails the SEG-Y ASCII contract.
9343 """
94- rows = _split_rows ( text_header )
44+ rows = text_header . split ( " \n " )
9545
9646 if len (rows ) != EXPECTED_ROWS :
9747 err = f"Invalid text header line count: expected { EXPECTED_ROWS } , got { len (rows )} "
@@ -106,46 +56,30 @@ def validate_text_header(text_header: str) -> None:
10656
10757 bad_chars : dict [int , list [int ]] = {}
10858 for i , row in enumerate (rows ):
109- positions = _find_unsafe (row )
59+ positions = [ j for j , c in enumerate (row ) if not _is_safe_char ( c )]
11060 if positions :
11161 bad_chars [i ] = positions
11262
11363 if bad_chars :
114- err = (
115- "Invalid text header characters: non-ASCII or non-printable at "
116- f"{ _summarize (bad_chars )} "
117- )
64+ err = f"Invalid text header characters: non-ASCII or non-printable at { _summarize (bad_chars )} "
11865 raise ValueError (err )
11966
12067
12168def sanitize_text_header (text_header : str ) -> str :
122- """Coerce a SEG-Y textual file header into the 40x80 ASCII-printable card layout.
123-
124- Pre-processing collapses runs of two or more ``"\\ n"`` into one. Some SEG-Y
125- writers terminate each card with ``"\\ n\\ n"``, which yields 80 rows on a
126- naive ``split("\\ n")`` and would silently drop cards 21-40 when the row
127- list is sliced to 40. Collapsing runs of newlines recovers the intended
128- card layout for that common case while leaving properly-wrapped headers
129- untouched.
130-
131- The normalized input is then split on ``"\\ n"`` and each row is independently:
132-
133- 1. Stripped of unsafe characters (any non-ASCII or non-printable codepoint
134- is replaced with a single ASCII space).
135- 2. Right-padded with spaces or truncated to exactly 80 characters.
69+ r"""Coerce a SEG-Y textual file header into the 40x80 ASCII-printable card layout.
13670
137- Rows beyond 40 are dropped. Missing rows are appended as 80-space blanks
138- so the result always contains exactly 40 lines.
71+ Runs of two or more ``\n`` collapse to one (some writers terminate cards with ``\n\n``).
72+ Each row gets unsafe characters replaced with spaces and is padded/truncated to 80 chars.
73+ The result always has exactly 40 rows.
13974
14075 Args:
14176 text_header: Decoded textual file header string.
14277
14378 Returns:
144- Sanitized header string with rows joined by ``"\\ n"``. The output is
145- guaranteed to satisfy :func:`validate_text_header`.
79+ Sanitized header that satisfies :func:`validate_text_header`.
14680 """
14781 normalized = _NEWLINE_RUN .sub ("\n " , text_header )
148- rows = _split_rows ( normalized )
82+ rows = normalized . split ( " \n " )
14983
15084 sanitized : list [str ] = []
15185 for row in rows [:EXPECTED_ROWS ]:
0 commit comments