fast-sentence-segment 1.2.1__py3-none-any.whl → 1.4.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.
@@ -3,11 +3,44 @@ from .svc import *
3
3
  from .dmo import *
4
4
 
5
5
  from .bp.segmenter import Segmenter
6
+ from .dmo.unwrap_hard_wrapped_text import unwrap_hard_wrapped_text
7
+ from .dmo.normalize_quotes import normalize_quotes
6
8
 
7
9
  segment = Segmenter().input_text
8
10
 
9
11
 
10
- def segment_text(input_text: str, flatten: bool = False) -> list:
12
+ def segment_text(
13
+ input_text: str,
14
+ flatten: bool = False,
15
+ unwrap: bool = False,
16
+ normalize: bool = True,
17
+ ) -> list:
18
+ """Segment text into sentences.
19
+
20
+ Args:
21
+ input_text: The text to segment.
22
+ flatten: If True, return a flat list of sentences instead of
23
+ nested paragraphs.
24
+ unwrap: If True, unwrap hard-wrapped lines (e.g., Project
25
+ Gutenberg e-texts) before segmenting.
26
+ normalize: If True (default), normalize unicode quote variants
27
+ to ASCII equivalents before segmenting. Ensures consistent
28
+ quote characters for downstream processing.
29
+
30
+ Returns:
31
+ List of sentences (if flatten=True) or list of paragraph
32
+ groups, each containing a list of sentences.
33
+
34
+ Related GitHub Issue:
35
+ #6 - Review findings from Issue #5
36
+ https://github.com/craigtrim/fast-sentence-segment/issues/6
37
+ """
38
+ if unwrap:
39
+ input_text = unwrap_hard_wrapped_text(input_text)
40
+
41
+ if normalize:
42
+ input_text = normalize_quotes(input_text)
43
+
11
44
  results = segment(input_text)
12
45
 
13
46
  if flatten:
@@ -3,12 +3,45 @@
3
3
 
4
4
  import argparse
5
5
  import logging
6
+ import os
6
7
  import sys
8
+ import time
7
9
 
8
10
  from fast_sentence_segment import segment_text
11
+ from fast_sentence_segment.dmo.group_quoted_sentences import format_grouped_sentences
9
12
 
10
13
  logging.disable(logging.CRITICAL)
11
14
 
15
+ # ANSI color codes
16
+ BOLD = "\033[1m"
17
+ DIM = "\033[2m"
18
+ CYAN = "\033[36m"
19
+ GREEN = "\033[32m"
20
+ YELLOW = "\033[33m"
21
+ RESET = "\033[0m"
22
+
23
+
24
+ def _header(title: str):
25
+ print(f"\n{BOLD}{CYAN}{title}{RESET}")
26
+ print(f"{DIM}{'─' * 40}{RESET}")
27
+
28
+
29
+ def _param(label: str, value: str):
30
+ print(f" {DIM}{label}:{RESET} {value}")
31
+
32
+
33
+ def _done(msg: str):
34
+ print(f"\n {GREEN}✓{RESET} {msg}")
35
+
36
+
37
+ def _file_size(path: str) -> str:
38
+ size = os.path.getsize(path)
39
+ if size < 1024:
40
+ return f"{size} B"
41
+ elif size < 1024 * 1024:
42
+ return f"{size / 1024:.1f} KB"
43
+ return f"{size / (1024 * 1024):.1f} MB"
44
+
12
45
 
13
46
  def main():
14
47
  parser = argparse.ArgumentParser(
@@ -29,6 +62,11 @@ def main():
29
62
  action="store_true",
30
63
  help="Number output lines",
31
64
  )
65
+ parser.add_argument(
66
+ "--unwrap",
67
+ action="store_true",
68
+ help="Unwrap hard-wrapped lines and dehyphenate split words",
69
+ )
32
70
  args = parser.parse_args()
33
71
 
34
72
  # Get input text
@@ -44,7 +82,7 @@ def main():
44
82
  sys.exit(1)
45
83
 
46
84
  # Segment and output
47
- sentences = segment_text(text.strip(), flatten=True)
85
+ sentences = segment_text(text.strip(), flatten=True, unwrap=args.unwrap)
48
86
  for i, sentence in enumerate(sentences, 1):
49
87
  if args.numbered:
50
88
  print(f"{i}. {sentence}")
@@ -52,5 +90,60 @@ def main():
52
90
  print(sentence)
53
91
 
54
92
 
93
+ def file_main():
94
+ parser = argparse.ArgumentParser(
95
+ prog="segment-file",
96
+ description="Segment a text file into sentences and write to an output file",
97
+ )
98
+ parser.add_argument(
99
+ "--input-file", required=True,
100
+ help="Path to input text file",
101
+ )
102
+ parser.add_argument(
103
+ "--output-file", required=True,
104
+ help="Path to output file",
105
+ )
106
+ parser.add_argument(
107
+ "--unwrap", action="store_true",
108
+ help="Unwrap hard-wrapped lines (e.g., Project Gutenberg e-texts)",
109
+ )
110
+ parser.add_argument(
111
+ "--no-normalize-quotes", action="store_true",
112
+ help="Disable unicode quote normalization to ASCII equivalents",
113
+ )
114
+ args = parser.parse_args()
115
+
116
+ _header("segment-file")
117
+ _param("Input", args.input_file)
118
+ _param("Output", args.output_file)
119
+ _param("Size", _file_size(args.input_file))
120
+ if args.unwrap:
121
+ _param("Unwrap", "enabled")
122
+
123
+ print(f"\n {YELLOW}Segmenting...{RESET}", end="", flush=True)
124
+
125
+ with open(args.input_file, "r", encoding="utf-8") as f:
126
+ text = f.read()
127
+
128
+ start = time.perf_counter()
129
+ normalize = not args.no_normalize_quotes
130
+ sentences = segment_text(
131
+ text.strip(), flatten=True, unwrap=args.unwrap, normalize=normalize,
132
+ )
133
+ elapsed = time.perf_counter() - start
134
+
135
+ with open(args.output_file, "w", encoding="utf-8") as f:
136
+ if args.unwrap:
137
+ f.write(format_grouped_sentences(sentences) + "\n")
138
+ else:
139
+ for sentence in sentences:
140
+ f.write(sentence + "\n")
141
+
142
+ print(f"\r {' ' * 20}\r", end="")
143
+ _done(f"{len(sentences):,} sentences in {elapsed:.2f}s")
144
+ _done(f"Written to {args.output_file}")
145
+ print()
146
+
147
+
55
148
  if __name__ == "__main__":
56
149
  main()
@@ -2,9 +2,14 @@ from .abbreviation_merger import AbbreviationMerger
2
2
  from .abbreviation_splitter import AbbreviationSplitter
3
3
  from .title_name_merger import TitleNameMerger
4
4
  from .bullet_point_cleaner import BulletPointCleaner
5
+ from .dehyphenator import Dehyphenator
5
6
  from .ellipsis_normalizer import EllipsisNormalizer
6
7
  from .newlines_to_periods import NewlinesToPeriods
7
8
  from .post_process_sentences import PostProcessStructure
8
9
  from .question_exclamation_splitter import QuestionExclamationSplitter
9
10
  from .spacy_doc_segmenter import SpacyDocSegmenter
10
11
  from .numbered_list_normalizer import NumberedListNormalizer
12
+ from .unwrap_hard_wrapped_text import unwrap_hard_wrapped_text
13
+ from .normalize_quotes import normalize_quotes
14
+ from .group_quoted_sentences import group_quoted_sentences, format_grouped_sentences
15
+ from .strip_trailing_period_after_quote import StripTrailingPeriodAfterQuote
@@ -20,6 +20,8 @@ SENTENCE_ENDING_ABBREVIATIONS: List[str] = [
20
20
  # Common sentence-enders
21
21
  "etc.",
22
22
  "ext.",
23
+ "approx.",
24
+ "dept.",
23
25
 
24
26
  # Academic degrees (when at end of sentence)
25
27
  "Ph.D.",
@@ -32,6 +34,9 @@ SENTENCE_ENDING_ABBREVIATIONS: List[str] = [
32
34
  "J.D.",
33
35
  "D.D.S.",
34
36
  "R.N.",
37
+ "M.B.A.",
38
+ "LL.B.",
39
+ "LL.M.",
35
40
 
36
41
  # Business (when at end of sentence)
37
42
  "Inc.",
@@ -39,6 +44,14 @@ SENTENCE_ENDING_ABBREVIATIONS: List[str] = [
39
44
  "Ltd.",
40
45
  "Co.",
41
46
  "Bros.",
47
+ "LLC.",
48
+ "LLP.",
49
+
50
+ # Academic/legal citations (can end sentences)
51
+ "ibid.",
52
+ "Ibid.",
53
+ "cf.",
54
+ "Cf.",
42
55
 
43
56
  # Countries/Regions (when at end of sentence)
44
57
  "U.S.",
@@ -62,23 +75,59 @@ TITLE_ABBREVIATIONS: List[str] = [
62
75
  "Sr.",
63
76
  "Jr.",
64
77
  "Rev.",
78
+ "Hon.",
79
+ "Esq.",
80
+
81
+ # French/formal titles (common in translated literature)
82
+ "Mme.",
83
+ "Mlle.",
84
+ "Messrs.",
85
+
86
+ # Military ranks
65
87
  "Gen.",
66
88
  "Col.",
67
89
  "Capt.",
68
90
  "Lt.",
69
91
  "Sgt.",
92
+ "Maj.",
93
+ "Cpl.",
94
+ "Pvt.",
95
+ "Adm.",
96
+ "Cmdr.",
97
+
98
+ # Political titles
70
99
  "Rep.",
71
100
  "Sen.",
72
101
  "Gov.",
73
102
  "Pres.",
74
- "Hon.",
103
+
104
+ # Ecclesiastical titles
105
+ "Fr.",
106
+ "Msgr.",
75
107
 
76
108
  # Geographic prefixes
77
109
  "St.",
78
110
  "Mt.",
79
111
  "Ft.",
112
+ "Ave.",
113
+ "Blvd.",
114
+ "Rd.",
80
115
 
81
- # Other prefixes
116
+ # Latin terms (never end sentences -- always introduce clauses)
117
+ # Include common inconsistent forms: with/without internal periods,
118
+ # and with trailing comma (the most common real-world form)
119
+ "i.e.",
120
+ "i.e.,",
121
+ "ie.",
122
+ "ie.,",
123
+ "e.g.",
124
+ "e.g.,",
125
+ "eg.",
126
+ "eg.,",
127
+ "viz.",
128
+ "viz.,",
129
+
130
+ # Reference/numbering prefixes
82
131
  "Fig.",
83
132
  "fig.",
84
133
  "Sec.",
@@ -93,4 +142,8 @@ TITLE_ABBREVIATIONS: List[str] = [
93
142
  "no.",
94
143
  "Pt.",
95
144
  "pt.",
145
+
146
+ # Legal / adversarial
147
+ "vs.",
148
+ "Vs.",
96
149
  ]
@@ -0,0 +1,55 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Dehyphenate words split across lines.
3
+
4
+ Related GitHub Issue:
5
+ #8 - Add dehyphenation support for words split across lines
6
+ https://github.com/craigtrim/fast-sentence-segment/issues/8
7
+
8
+ When processing ebooks and scanned documents, words are often hyphenated
9
+ at line breaks for typesetting purposes. This module rejoins those words.
10
+ """
11
+
12
+ import re
13
+
14
+ from fast_sentence_segment.core import BaseObject
15
+
16
+ # Pattern to match hyphenated word breaks at end of line:
17
+ # - A single hyphen (not -- em-dash)
18
+ # - Followed by newline and optional whitespace
19
+ # - Followed by a lowercase letter (continuation of word)
20
+ _HYPHEN_LINE_BREAK_PATTERN = re.compile(r'(?<!-)-\n\s*([a-z])')
21
+
22
+
23
+ class Dehyphenator(BaseObject):
24
+ """Rejoin words that were hyphenated across line breaks."""
25
+
26
+ def __init__(self):
27
+ """Change Log
28
+
29
+ Created:
30
+ 3-Feb-2026
31
+ craigtrim@gmail.com
32
+ * add dehyphenation support for words split across lines
33
+ https://github.com/craigtrim/fast-sentence-segment/issues/8
34
+ """
35
+ BaseObject.__init__(self, __name__)
36
+
37
+ @staticmethod
38
+ def process(input_text: str) -> str:
39
+ """Rejoin words that were hyphenated across line breaks.
40
+
41
+ Detects the pattern of a word fragment ending with a hyphen
42
+ at the end of a line, followed by the word continuation
43
+ starting with a lowercase letter on the next line.
44
+
45
+ Examples:
46
+ "bot-\\ntle" -> "bottle"
47
+ "cham-\\n bermaid" -> "chambermaid"
48
+
49
+ Args:
50
+ input_text: Text that may contain hyphenated line breaks.
51
+
52
+ Returns:
53
+ Text with hyphenated word breaks rejoined.
54
+ """
55
+ return _HYPHEN_LINE_BREAK_PATTERN.sub(r'\1', input_text)
@@ -0,0 +1,141 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Group sentences that belong to the same open-quote span.
3
+
4
+ When outputting segmented text with blank-line separators, sentences
5
+ that open with a double quote but do not close it should be grouped
6
+ with subsequent sentences (no blank line between them) until the
7
+ closing quote is found.
8
+
9
+ Related GitHub Issues:
10
+ #5 - Normalize quotes and group open-quote sentences in unwrap mode
11
+ https://github.com/craigtrim/fast-sentence-segment/issues/5
12
+
13
+ #6 - Review findings from Issue #5
14
+ https://github.com/craigtrim/fast-sentence-segment/issues/6
15
+ """
16
+
17
+ from typing import List
18
+
19
+ # Maximum number of sentences that can be grouped under a single
20
+ # open-quote span before the quote state is forcibly reset. This
21
+ # bounds the damage from a stray quote character (e.g., OCR artifact)
22
+ # which would otherwise corrupt grouping for all subsequent sentences.
23
+ #
24
+ # A typical quoted passage in literature rarely exceeds 20 sentences.
25
+ # This limit is deliberately generous to avoid false resets on
26
+ # legitimately long quoted passages while still preventing runaway
27
+ # grouping on malformed input.
28
+ MAX_QUOTE_GROUP_SIZE = 20
29
+
30
+
31
+ def group_quoted_sentences(sentences: List[str]) -> List[List[str]]:
32
+ """Group sentences into blocks based on open/close quote tracking.
33
+
34
+ Sentences within an unclosed double-quote span are grouped together
35
+ into the same block. Sentences outside of a quote span each form
36
+ their own block.
37
+
38
+ When rendered, each block is joined by newlines, and blocks are
39
+ separated by blank lines (double newlines).
40
+
41
+ The algorithm tracks the quote state by counting ASCII double quote
42
+ characters in each sentence. An odd count toggles the open/close
43
+ state. When a quote is open, subsequent sentences are appended to
44
+ the current group rather than starting a new one.
45
+
46
+ A safety limit (MAX_QUOTE_GROUP_SIZE) prevents a stray or malformed
47
+ quote from swallowing all remaining sentences into one group. When
48
+ the limit is reached, the current group is flushed and the quote
49
+ state is reset. This bounds corruption from OCR artifacts or
50
+ encoding errors to a bounded window rather than the entire document.
51
+
52
+ Args:
53
+ sentences: Flat list of segmented sentences.
54
+
55
+ Returns:
56
+ List of sentence groups. Each group is a list of sentences
57
+ that should be rendered together without blank-line separators.
58
+
59
+ Example:
60
+ >>> groups = group_quoted_sentences([
61
+ ... '"The probability lies in that direction.',
62
+ ... 'And if we take this as a working hypothesis."',
63
+ ... 'He paused.',
64
+ ... ])
65
+ >>> groups
66
+ [['"The probability lies in that direction.',
67
+ 'And if we take this as a working hypothesis."'],
68
+ ['He paused.']]
69
+
70
+ Related GitHub Issues:
71
+ #5 - Normalize quotes and group open-quote sentences in unwrap mode
72
+ https://github.com/craigtrim/fast-sentence-segment/issues/5
73
+
74
+ #6 - Review findings from Issue #5
75
+ https://github.com/craigtrim/fast-sentence-segment/issues/6
76
+ """
77
+ if not sentences:
78
+ return []
79
+
80
+ groups: List[List[str]] = []
81
+ current_group: List[str] = []
82
+ quote_open = False
83
+
84
+ for sentence in sentences:
85
+ quote_count = sentence.count('"')
86
+
87
+ if not quote_open:
88
+ # Starting a new group
89
+ if current_group:
90
+ groups.append(current_group)
91
+ current_group = [sentence]
92
+ else:
93
+ # Inside an open quote span -- append to current group
94
+ current_group.append(sentence)
95
+
96
+ # Toggle quote state on odd quote count
97
+ if quote_count % 2 == 1:
98
+ quote_open = not quote_open
99
+
100
+ # Safety: if a group grows beyond the limit, the quote is
101
+ # likely corrupted (stray quote character). Flush the group
102
+ # and reset state to prevent runaway grouping.
103
+ if quote_open and len(current_group) >= MAX_QUOTE_GROUP_SIZE:
104
+ groups.append(current_group)
105
+ current_group = []
106
+ quote_open = False
107
+
108
+ # Flush the final group
109
+ if current_group:
110
+ groups.append(current_group)
111
+
112
+ return groups
113
+
114
+
115
+ def format_grouped_sentences(sentences: List[str]) -> str:
116
+ """Format sentences with quote-aware blank-line separation.
117
+
118
+ Sentences within the same quoted span are separated by single
119
+ newlines. Sentence groups are separated by blank lines (double
120
+ newlines).
121
+
122
+ Args:
123
+ sentences: Flat list of segmented sentences.
124
+
125
+ Returns:
126
+ Formatted string with appropriate line separation.
127
+
128
+ Example:
129
+ >>> text = format_grouped_sentences([
130
+ ... '"The probability lies in that direction.',
131
+ ... 'And if we take this as a working hypothesis."',
132
+ ... 'He paused.',
133
+ ... ])
134
+ >>> print(text)
135
+ "The probability lies in that direction.
136
+ And if we take this as a working hypothesis."
137
+ <BLANKLINE>
138
+ He paused.
139
+ """
140
+ groups = group_quoted_sentences(sentences)
141
+ return '\n\n'.join('\n'.join(group) for group in groups)
@@ -0,0 +1,80 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Normalize unicode quote variants to ASCII equivalents.
3
+
4
+ E-texts use a variety of quote characters (curly/smart quotes, unicode
5
+ variants, primes, guillemets). This module normalizes all quote variants
6
+ to their standard ASCII equivalents: double quote (") and single
7
+ quote/apostrophe (').
8
+
9
+ Related GitHub Issues:
10
+ #5 - Normalize quotes and group open-quote sentences in unwrap mode
11
+ https://github.com/craigtrim/fast-sentence-segment/issues/5
12
+
13
+ #6 - Review findings from Issue #5
14
+ https://github.com/craigtrim/fast-sentence-segment/issues/6
15
+ """
16
+
17
+ import re
18
+
19
+ # Unicode double quote variants to normalize to ASCII " (U+0022).
20
+ #
21
+ # U+201C " LEFT DOUBLE QUOTATION MARK
22
+ # U+201D " RIGHT DOUBLE QUOTATION MARK
23
+ # U+201E „ DOUBLE LOW-9 QUOTATION MARK
24
+ # U+201F ‟ DOUBLE HIGH-REVERSED-9 QUOTATION MARK
25
+ # U+00AB « LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
26
+ # U+00BB » RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
27
+ # U+2033 ″ DOUBLE PRIME
28
+ # U+301D 〝 REVERSED DOUBLE PRIME QUOTATION MARK
29
+ # U+301E 〞 DOUBLE PRIME QUOTATION MARK
30
+ # U+301F 〟 LOW DOUBLE PRIME QUOTATION MARK
31
+ # U+FF02 " FULLWIDTH QUOTATION MARK
32
+ DOUBLE_QUOTE_PATTERN = re.compile(
33
+ '[\u201c\u201d\u201e\u201f\u00ab\u00bb\u2033\u301d\u301e\u301f\uff02]'
34
+ )
35
+
36
+ # Unicode single quote variants to normalize to ASCII ' (U+0027).
37
+ #
38
+ # U+2018 ' LEFT SINGLE QUOTATION MARK
39
+ # U+2019 ' RIGHT SINGLE QUOTATION MARK
40
+ # U+201A ‚ SINGLE LOW-9 QUOTATION MARK
41
+ # U+201B ‛ SINGLE HIGH-REVERSED-9 QUOTATION MARK
42
+ # U+2039 ‹ SINGLE LEFT-POINTING ANGLE QUOTATION MARK
43
+ # U+203A › SINGLE RIGHT-POINTING ANGLE QUOTATION MARK
44
+ # U+2032 ′ PRIME
45
+ # U+FF07 ' FULLWIDTH APOSTROPHE
46
+ # U+0060 ` GRAVE ACCENT (used as opening quote in some e-texts)
47
+ # U+00B4 ´ ACUTE ACCENT (used as closing quote in some e-texts)
48
+ SINGLE_QUOTE_PATTERN = re.compile(
49
+ '[\u2018\u2019\u201a\u201b\u2039\u203a\u2032\uff07\u0060\u00b4]'
50
+ )
51
+
52
+
53
+ def normalize_quotes(text: str) -> str:
54
+ """Replace all unicode quote variants with their ASCII equivalents.
55
+
56
+ Double quote variants are normalized to ASCII " (U+0022).
57
+ Single quote variants are normalized to ASCII ' (U+0027).
58
+
59
+ Args:
60
+ text: Input text potentially containing unicode quotes.
61
+
62
+ Returns:
63
+ Text with all quote variants replaced by ASCII equivalents.
64
+
65
+ Example:
66
+ >>> normalize_quotes('\u201cHello,\u201d she said.')
67
+ '"Hello," she said.'
68
+ >>> normalize_quotes('It\u2019s fine.')
69
+ "It's fine."
70
+
71
+ Related GitHub Issues:
72
+ #5 - Normalize quotes and group open-quote sentences in unwrap mode
73
+ https://github.com/craigtrim/fast-sentence-segment/issues/5
74
+
75
+ #6 - Review findings from Issue #5
76
+ https://github.com/craigtrim/fast-sentence-segment/issues/6
77
+ """
78
+ text = DOUBLE_QUOTE_PATTERN.sub('"', text)
79
+ text = SINGLE_QUOTE_PATTERN.sub("'", text)
80
+ return text
@@ -23,18 +23,31 @@ class SpacyDocSegmenter(BaseObject):
23
23
 
24
24
  @staticmethod
25
25
  def _append_period(a_sentence: str) -> str:
26
+ """Append a period if the sentence lacks terminal punctuation.
27
+
28
+ Checks for terminal punctuation (. ? ! :) after stripping any
29
+ trailing quote characters (" '). This prevents a spurious period
30
+ from being appended to sentences like:
31
+ 'He said "Hello."' -> unchanged (not 'He said "Hello.".')
32
+
33
+ Related GitHub Issue:
34
+ #7 - Spurious trailing period appended after sentence-final
35
+ closing quote
36
+ https://github.com/craigtrim/fast-sentence-segment/issues/7
37
+
38
+ Args:
39
+ a_sentence: A sentence that may or may not have terminal
40
+ punctuation.
41
+
42
+ Returns:
43
+ The sentence with a period appended if it lacked terminal
44
+ punctuation, otherwise unchanged.
26
45
  """
27
- Purpose:
28
- if the sentence is not terminated with a period, then add one
29
- :return:
30
- a sentence terminated by a period
31
- """
32
- __blacklist = [':', '?', '!']
33
- if not a_sentence.strip().endswith('.'):
34
- for ch in __blacklist:
35
- if not a_sentence.endswith(ch):
36
- return f"{a_sentence}."
37
- return a_sentence
46
+ # Strip trailing quotes to inspect the actual punctuation
47
+ stripped = a_sentence.strip().rstrip('"\'')
48
+ if stripped and stripped[-1] in '.?!:':
49
+ return a_sentence
50
+ return f"{a_sentence}."
38
51
 
39
52
  @staticmethod
40
53
  def _is_valid_sentence(a_sentence: str) -> bool:
@@ -0,0 +1,70 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Strip spurious trailing periods appended after sentence-final closing quotes.
3
+
4
+ The spaCy segmenter's _append_period method can produce sentences like:
5
+ 'He said "Hello.".' (spurious trailing period)
6
+ 'She asked "Why?".' (spurious trailing period)
7
+ 'He yelled "Stop!".' (spurious trailing period)
8
+
9
+ This post-processor removes the trailing period when the sentence ends
10
+ with a closing double quote preceded by terminal punctuation.
11
+
12
+ Related GitHub Issue:
13
+ #7 - Spurious trailing period appended after sentence-final
14
+ closing quote
15
+ https://github.com/craigtrim/fast-sentence-segment/issues/7
16
+ """
17
+
18
+ import re
19
+
20
+ from fast_sentence_segment.core import BaseObject
21
+
22
+ # Matches a sentence that ends with terminal punctuation (. ? !)
23
+ # followed by a closing double quote, followed by a spurious period.
24
+ # The fix strips the final period.
25
+ _SPURIOUS_PERIOD_PATTERN = re.compile(r'([.?!]")\.$')
26
+
27
+
28
+ class StripTrailingPeriodAfterQuote(BaseObject):
29
+ """Strip spurious trailing periods after sentence-final closing quotes.
30
+
31
+ Detects sentences ending with patterns like:
32
+ ."." -> ."
33
+ ?"." -> ?"
34
+ !"." -> !"
35
+
36
+ Applied as a post-processing step in the sentence segmentation
37
+ pipeline, after spaCy segmentation and after the existing
38
+ PostProcessStructure step.
39
+
40
+ Related GitHub Issue:
41
+ #7 - Spurious trailing period appended after sentence-final
42
+ closing quote
43
+ https://github.com/craigtrim/fast-sentence-segment/issues/7
44
+ """
45
+
46
+ def __init__(self):
47
+ """
48
+ Created:
49
+ 29-Jan-2026
50
+ """
51
+ BaseObject.__init__(self, __name__)
52
+
53
+ def process(self, sentences: list) -> list:
54
+ """Remove spurious trailing periods after closing quotes.
55
+
56
+ Args:
57
+ sentences: List of segmented sentences.
58
+
59
+ Returns:
60
+ List of sentences with spurious trailing periods removed.
61
+
62
+ Example:
63
+ >>> proc = StripTrailingPeriodAfterQuote()
64
+ >>> proc.process(['He said "Hello.".', 'She waved.'])
65
+ ['He said "Hello."', 'She waved.']
66
+ """
67
+ return [
68
+ _SPURIOUS_PERIOD_PATTERN.sub(r'\1', sentence)
69
+ for sentence in sentences
70
+ ]
@@ -0,0 +1,75 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Unwrap hard-wrapped text (e.g., Project Gutenberg e-texts).
3
+
4
+ Joins lines within paragraphs into continuous strings while
5
+ preserving paragraph boundaries (blank lines). Also dehyphenates
6
+ words that were split across lines for typesetting.
7
+
8
+ Related GitHub Issue:
9
+ #8 - Add dehyphenation support for words split across lines
10
+ https://github.com/craigtrim/fast-sentence-segment/issues/8
11
+ """
12
+
13
+ import re
14
+
15
+ # Pattern to match hyphenated word breaks at end of line:
16
+ # - A single hyphen (not -- em-dash)
17
+ # - Followed by newline and optional whitespace
18
+ # - Followed by a lowercase letter (continuation of word)
19
+ _HYPHEN_LINE_BREAK_PATTERN = re.compile(r'(?<!-)-\n\s*([a-z])')
20
+
21
+
22
+ def _dehyphenate_block(block: str) -> str:
23
+ """Remove hyphens from words split across lines.
24
+
25
+ Detects the pattern of a word fragment ending with a hyphen
26
+ at the end of a line, followed by the word continuation
27
+ starting with a lowercase letter on the next line.
28
+
29
+ Examples:
30
+ "bot-\\ntle" -> "bottle"
31
+ "cham-\\n bermaid" -> "chambermaid"
32
+
33
+ Args:
34
+ block: A paragraph block that may contain hyphenated line breaks.
35
+
36
+ Returns:
37
+ The block with hyphenated word breaks rejoined.
38
+ """
39
+ return _HYPHEN_LINE_BREAK_PATTERN.sub(r'\1', block)
40
+
41
+
42
+ def unwrap_hard_wrapped_text(text: str) -> str:
43
+ """Unwrap hard-wrapped paragraphs into continuous lines.
44
+
45
+ Splits on blank lines to identify paragraphs, then joins
46
+ lines within each paragraph into a single string with
47
+ single spaces. Also dehyphenates words that were split
48
+ across lines for typesetting purposes.
49
+
50
+ Examples:
51
+ >>> unwrap_hard_wrapped_text("a bot-\\ntle of wine")
52
+ 'a bottle of wine'
53
+ >>> unwrap_hard_wrapped_text("line one\\nline two")
54
+ 'line one line two'
55
+
56
+ Args:
57
+ text: Raw text with hard-wrapped lines.
58
+
59
+ Returns:
60
+ Text with paragraphs unwrapped into continuous strings,
61
+ separated by double newlines, with hyphenated words rejoined.
62
+ """
63
+ blocks = re.split(r'\n\s*\n', text)
64
+ unwrapped = []
65
+
66
+ for block in blocks:
67
+ # First, dehyphenate words split across lines
68
+ block = _dehyphenate_block(block)
69
+ # Then join remaining lines with spaces
70
+ lines = block.splitlines()
71
+ joined = ' '.join(line.strip() for line in lines if line.strip())
72
+ if joined:
73
+ unwrapped.append(joined)
74
+
75
+ return '\n\n'.join(unwrapped)
@@ -17,6 +17,8 @@ from fast_sentence_segment.dmo import NumberedListNormalizer
17
17
  from fast_sentence_segment.dmo import QuestionExclamationSplitter
18
18
  from fast_sentence_segment.dmo import SpacyDocSegmenter
19
19
  from fast_sentence_segment.dmo import PostProcessStructure
20
+ from fast_sentence_segment.dmo import StripTrailingPeriodAfterQuote
21
+ from fast_sentence_segment.dmo import Dehyphenator
20
22
 
21
23
 
22
24
  class PerformSentenceSegmentation(BaseObject):
@@ -45,6 +47,7 @@ class PerformSentenceSegmentation(BaseObject):
45
47
  if not self.__nlp:
46
48
  self.__nlp = spacy.load("en_core_web_sm")
47
49
 
50
+ self._dehyphenate = Dehyphenator.process
48
51
  self._newlines_to_periods = NewlinesToPeriods.process
49
52
  self._normalize_numbered_lists = NumberedListNormalizer().process
50
53
  self._normalize_ellipses = EllipsisNormalizer().process
@@ -55,6 +58,7 @@ class PerformSentenceSegmentation(BaseObject):
55
58
  self._question_exclamation_splitter = QuestionExclamationSplitter().process
56
59
  self._title_name_merger = TitleNameMerger().process
57
60
  self._post_process = PostProcessStructure().process
61
+ self._strip_trailing_period = StripTrailingPeriodAfterQuote().process
58
62
 
59
63
  def _denormalize(self, text: str) -> str:
60
64
  """ Restore normalized placeholders to original form """
@@ -94,6 +98,10 @@ class PerformSentenceSegmentation(BaseObject):
94
98
  # Normalize tabs to spaces
95
99
  input_text = input_text.replace('\t', ' ')
96
100
 
101
+ # Dehyphenate words split across lines (issue #8)
102
+ # Must happen before newlines are converted to periods
103
+ input_text = self._dehyphenate(input_text)
104
+
97
105
  input_text = self._normalize_numbered_lists(input_text)
98
106
  input_text = self._normalize_ellipses(input_text)
99
107
 
@@ -129,6 +137,9 @@ class PerformSentenceSegmentation(BaseObject):
129
137
 
130
138
  sentences = self._post_process(sentences)
131
139
 
140
+ # Strip spurious trailing periods after closing quotes (issue #7)
141
+ sentences = self._strip_trailing_period(sentences)
142
+
132
143
  sentences = [
133
144
  self._normalize_numbered_lists(x, denormalize=True)
134
145
  for x in sentences
@@ -1,25 +1,29 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: fast-sentence-segment
3
- Version: 1.2.1
3
+ Version: 1.4.0
4
4
  Summary: Fast and Efficient Sentence Segmentation
5
+ Home-page: https://github.com/craigtrim/fast-sentence-segment
5
6
  License: MIT
6
- License-File: LICENSE
7
7
  Keywords: nlp,text,preprocess,segment
8
8
  Author: Craig Trim
9
9
  Author-email: craigtrim@gmail.com
10
10
  Maintainer: Craig Trim
11
11
  Maintainer-email: craigtrim@gmail.com
12
12
  Requires-Python: >=3.9,<4.0
13
- Classifier: Development Status :: 4 - Beta
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
14
16
  Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
15
18
  Classifier: Programming Language :: Python :: 3
16
19
  Classifier: Programming Language :: Python :: 3.9
17
20
  Classifier: Programming Language :: Python :: 3.10
18
21
  Classifier: Programming Language :: Python :: 3.11
19
22
  Classifier: Programming Language :: Python :: 3.12
20
- Classifier: Programming Language :: Python :: 3.13
21
- Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: Text Processing :: Linguistic
26
+ Classifier: Typing :: Typed
23
27
  Requires-Dist: spacy (>=3.8.0,<4.0.0)
24
28
  Project-URL: Bug Tracker, https://github.com/craigtrim/fast-sentence-segment/issues
25
29
  Project-URL: Repository, https://github.com/craigtrim/fast-sentence-segment
@@ -29,8 +33,11 @@ Description-Content-Type: text/markdown
29
33
 
30
34
  [![PyPI version](https://img.shields.io/pypi/v/fast-sentence-segment.svg)](https://pypi.org/project/fast-sentence-segment/)
31
35
  [![Python versions](https://img.shields.io/pypi/pyversions/fast-sentence-segment.svg)](https://pypi.org/project/fast-sentence-segment/)
36
+ [![CI](https://img.shields.io/github/actions/workflow/status/craigtrim/fast-sentence-segment/ci.yml?branch=master&label=CI)](https://github.com/craigtrim/fast-sentence-segment/actions/workflows/ci.yml)
32
37
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
33
- [![spaCy](https://img.shields.io/badge/spaCy-3.8-blue.svg)](https://spacy.io/)
38
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
39
+ [![Downloads](https://static.pepy.tech/badge/fast-sentence-segment)](https://pepy.tech/project/fast-sentence-segment)
40
+ [![Downloads/Month](https://static.pepy.tech/badge/fast-sentence-segment/month)](https://pepy.tech/project/fast-sentence-segment)
34
41
 
35
42
  Fast and efficient sentence segmentation using spaCy with surgical post-processing fixes. Handles complex edge cases like abbreviations (Dr., Mr., etc.), ellipses, quoted text, and multi-paragraph documents.
36
43
 
@@ -142,48 +149,36 @@ results = segmenter.input_text("Your text here.")
142
149
 
143
150
  ### Command Line Interface
144
151
 
145
- Segment text directly from the terminal:
146
-
147
152
  ```bash
148
- # Direct text input
149
- echo "Have you seen Dr. Who? It's brilliant!" | segment
150
- ```
153
+ # Inline text
154
+ segment "Gandalf paused... You shall not pass! The Balrog roared."
151
155
 
152
- ```
153
- Have you seen Dr. Who?
154
- It's brilliant!
155
- ```
156
+ # Pipe from stdin
157
+ echo "Have you seen Dr. Who? It's brilliant!" | segment
156
158
 
157
- ```bash
158
159
  # Numbered output
159
- segment -n "Gandalf paused... You shall not pass! The Balrog roared."
160
- ```
160
+ segment -n -f silmarillion.txt
161
161
 
162
- ```
163
- 1. Gandalf paused...
164
- 2. You shall not pass!
165
- 3. The Balrog roared.
166
- ```
162
+ # File-to-file (one sentence per line)
163
+ segment-file --input-file book.txt --output-file sentences.txt
167
164
 
168
- ```bash
169
- # From file
170
- segment -f silmarillion.txt
165
+ # Unwrap hard-wrapped e-texts (Project Gutenberg, etc.)
166
+ segment-file --input-file book.txt --output-file sentences.txt --unwrap
171
167
  ```
172
168
 
173
169
  ## API Reference
174
170
 
175
171
  | Function | Parameters | Returns | Description |
176
172
  |----------|------------|---------|-------------|
177
- | `segment_text()` | `input_text: str`, `flatten: bool = False` | `list` | Main entry point for segmentation |
173
+ | `segment_text()` | `input_text: str`, `flatten: bool = False`, `unwrap: bool = False` | `list` | Main entry point for segmentation |
178
174
  | `Segmenter.input_text()` | `input_text: str` | `list[list[str]]` | Cached paragraph-aware segmentation |
179
175
 
180
- ### CLI Options
176
+ ### CLI Commands
181
177
 
182
- | Option | Description |
183
- |--------|-------------|
184
- | `text` | Text to segment (positional argument) |
185
- | `-f, --file` | Read text from file |
186
- | `-n, --numbered` | Number output lines |
178
+ | Command | Description |
179
+ |---------|-------------|
180
+ | `segment [text]` | Segment text from argument, `-f FILE`, or stdin. Use `-n` for numbered output. |
181
+ | `segment-file --input-file IN --output-file OUT [--unwrap]` | Segment a file and write one sentence per line. Use `--unwrap` for hard-wrapped e-texts. |
187
182
 
188
183
  ## Why Nested Lists?
189
184
 
@@ -1,27 +1,32 @@
1
- fast_sentence_segment/__init__.py,sha256=HTONyC0JLVWTAHyvJO6rMINmxymbfMpARtGeRw5iIsQ,359
1
+ fast_sentence_segment/__init__.py,sha256=jeb4yCy89ivyqbo-4ldJLquPAG_XR_33Q7nrDjqPxvE,1465
2
2
  fast_sentence_segment/bp/__init__.py,sha256=j2-WfQ9WwVuXeGSjvV6XLVwEdvau8sdAQe4Pa4DrYi8,33
3
3
  fast_sentence_segment/bp/segmenter.py,sha256=UW6DguPgA56h-pPYRsfJhjIzBe40j6NdjkwYxamASyA,1928
4
- fast_sentence_segment/cli.py,sha256=X2dMLkfc2dtheig62wKC75AohfW0Y9oTU0ORhGUFkbQ,1250
4
+ fast_sentence_segment/cli.py,sha256=Y89BH-xbJ0vykg301D2543MtGP4kYLnA6i3UQ7Hg5YA,3869
5
5
  fast_sentence_segment/core/__init__.py,sha256=uoBersYyVStJ5a8zJpQz1GDGaloEdAv2jGHw1292hRM,108
6
6
  fast_sentence_segment/core/base_object.py,sha256=AYr7yzusIwawjbKdvcv4yTEnhmx6M583kDZzhzPOmq4,635
7
7
  fast_sentence_segment/core/stopwatch.py,sha256=hE6hMz2q6rduaKi58KZmiAL-lRtyh_wWCANhl4KLkRQ,879
8
- fast_sentence_segment/dmo/__init__.py,sha256=E70tkpdHu86KP2dwBX5Dy5D7eNiU6fzucrfDJOY1ui4,551
8
+ fast_sentence_segment/dmo/__init__.py,sha256=N0lLHVn6zKeg6h1LIfoc4XeXPUY-uSbMT45dP2_vn8M,862
9
9
  fast_sentence_segment/dmo/abbreviation_merger.py,sha256=tCXM6yCfMryJvMIVWIxP_EocoibZi8vohFzJ5tvMYr0,4432
10
10
  fast_sentence_segment/dmo/abbreviation_splitter.py,sha256=03mSyJcLooNyIjXx6mPlrnjmKgZW-uhUIqG4U-MbIGw,2981
11
- fast_sentence_segment/dmo/abbreviations.py,sha256=7mpEoOnw5MH8URYmmpxaYs3Wc2eqy4pC0hAnYfYSdck,1639
11
+ fast_sentence_segment/dmo/abbreviations.py,sha256=CGJrJDo6pmYd3pTNEQbdOo8N6tnkCnwyL2X7Si663Os,2530
12
12
  fast_sentence_segment/dmo/bullet_point_cleaner.py,sha256=WOZQRWXiiyRi8rOuEIw36EmkaXmATHL9_Dxb2rderw4,1606
13
+ fast_sentence_segment/dmo/dehyphenator.py,sha256=6BJTie7tClRAifeiW8V2CdAAbcbknhtqmKylAdRZ7ko,1776
13
14
  fast_sentence_segment/dmo/ellipsis_normalizer.py,sha256=lHs9dLFfKJe-2vFNe17Hik90g3_kXX347OzGP_IOT08,1521
15
+ fast_sentence_segment/dmo/group_quoted_sentences.py,sha256=Ifh_kUwi7sMbzbZvrTgEKkzWe50AafUDhVKVPR9h7wQ,5092
14
16
  fast_sentence_segment/dmo/newlines_to_periods.py,sha256=PUrXreqZWiITINfoJL5xRRlXJH6noH0cdXtW1EqAh8I,1517
17
+ fast_sentence_segment/dmo/normalize_quotes.py,sha256=mr53qo_tj_I9XzElOKjUQvCtDQh7mBCGy-iqsHZDX14,2881
15
18
  fast_sentence_segment/dmo/numbered_list_normalizer.py,sha256=q0sOCW8Jkn2vTXlUcVhmDvYES3yvJx1oUVl_8y7eL4E,1672
16
19
  fast_sentence_segment/dmo/post_process_sentences.py,sha256=5jxG3TmFjxIExMPLhnCB5JT1lXQvFU9r4qQGoATGrWk,916
17
20
  fast_sentence_segment/dmo/question_exclamation_splitter.py,sha256=cRsWRu8zb6wOWG-BjMahHfz4YGutKiV9lW7dE-q3tgc,2006
18
- fast_sentence_segment/dmo/spacy_doc_segmenter.py,sha256=0icAkSQwAUQo3VYqQ2PUjW6-MOU5RNCGPX3-fB5YfCc,2554
21
+ fast_sentence_segment/dmo/spacy_doc_segmenter.py,sha256=_oTsrIL2rjysjt_8bPJVNTn230pUtL-geCC8g174iC4,3163
22
+ fast_sentence_segment/dmo/strip_trailing_period_after_quote.py,sha256=wYkoLy5XJKZIblJXBvDAB8-a81UTQOhOf2u91wjJWUw,2259
19
23
  fast_sentence_segment/dmo/title_name_merger.py,sha256=zbG04_VjwM8TtT8LhavvmZqIZL_2xgT2OTxWkK_Zt1s,5133
24
+ fast_sentence_segment/dmo/unwrap_hard_wrapped_text.py,sha256=V1T5RsJBaII_iGJMyWvv6rb2mny8pnVd428oVZL0n5I,2457
20
25
  fast_sentence_segment/svc/__init__.py,sha256=9B12mXxBnlalH4OAm1AMLwUMa-RLi2ilv7qhqv26q7g,144
21
26
  fast_sentence_segment/svc/perform_paragraph_segmentation.py,sha256=zLKw9rSzb0NNfx4MyEeoGrHwhxTtH5oDrYcAL2LMVHY,1378
22
- fast_sentence_segment/svc/perform_sentence_segmentation.py,sha256=dqGxFsJoP6ox_MJwtB85R9avEbBAR4x9YKaRaQ5fAXo,5723
23
- fast_sentence_segment-1.2.1.dist-info/METADATA,sha256=OsUlH-UhmI6fw-ChvsF83G_WwTXBlhZPINo243CaziQ,6889
24
- fast_sentence_segment-1.2.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
25
- fast_sentence_segment-1.2.1.dist-info/entry_points.txt,sha256=mDiRuKOZlOeqmtH1eZwqGEGM6KUh0RTzwyETGMpxSDI,58
26
- fast_sentence_segment-1.2.1.dist-info/licenses/LICENSE,sha256=vou5JCLAT5nHcsUv-AkjUYAihYfN9mwPDXxV2DHyHBo,1067
27
- fast_sentence_segment-1.2.1.dist-info/RECORD,,
27
+ fast_sentence_segment/svc/perform_sentence_segmentation.py,sha256=Qaj7oxVHfUd6pIlwCD1O8P14LaGUdolFGuykmrF6gw8,6276
28
+ fast_sentence_segment-1.4.0.dist-info/LICENSE,sha256=vou5JCLAT5nHcsUv-AkjUYAihYfN9mwPDXxV2DHyHBo,1067
29
+ fast_sentence_segment-1.4.0.dist-info/METADATA,sha256=XernLjKJbfSBxiqhQm-SZC8JFtggSoPe1hUD2X6D9N8,7853
30
+ fast_sentence_segment-1.4.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
31
+ fast_sentence_segment-1.4.0.dist-info/entry_points.txt,sha256=Zc8OwFKj3ofnjy5ZIFqHzDkIEWweV1AP1xap1ZFGD8M,107
32
+ fast_sentence_segment-1.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 1.9.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,3 +1,4 @@
1
1
  [console_scripts]
2
2
  segment=fast_sentence_segment.cli:main
3
+ segment-file=fast_sentence_segment.cli:file_main
3
4