markdowncleaner 0.1.1__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.
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+ __version__ = version("markdowncleaner")
3
+ from .markdowncleaner import CleanerOptions, MarkdownCleaner
markdowncleaner/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command-line interface for the markdowncleaner package.
4
+ """
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from markdowncleaner.markdowncleaner import MarkdownCleaner, CleanerOptions
12
+ from markdowncleaner.config.loader import get_default_patterns, CleaningPatterns
13
+
14
+
15
+ def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace:
16
+ """Parse command line arguments."""
17
+ parser = argparse.ArgumentParser(
18
+ description="Clean and format markdown files by removing unwanted sections, lines, and patterns."
19
+ )
20
+
21
+ parser.add_argument(
22
+ "input_file",
23
+ type=Path,
24
+ help="Path to the markdown file to clean"
25
+ )
26
+
27
+ parser.add_argument(
28
+ "-o", "--output",
29
+ type=Path,
30
+ help="Path to save the cleaned markdown file (default: input_file with '_cleaned' suffix)"
31
+ )
32
+
33
+ parser.add_argument(
34
+ "--output-dir",
35
+ type=Path,
36
+ help="Directory to save the cleaned file (default: same as input file)"
37
+ )
38
+
39
+ parser.add_argument(
40
+ "--config",
41
+ type=Path,
42
+ help="Path to custom YAML configuration file with cleaning patterns"
43
+ )
44
+
45
+ # Options for customizing the cleaning process
46
+ parser.add_argument(
47
+ "--keep-short-lines",
48
+ action="store_true",
49
+ help="Don't remove lines shorter than minimum length"
50
+ )
51
+
52
+ parser.add_argument(
53
+ "--min-line-length",
54
+ type=int,
55
+ default=70,
56
+ help="Minimum line length to keep (default: 70)"
57
+ )
58
+
59
+ parser.add_argument(
60
+ "--keep-bad-lines",
61
+ action="store_true",
62
+ help="Don't remove lines matching bad line patterns"
63
+ )
64
+
65
+ parser.add_argument(
66
+ "--keep-sections",
67
+ action="store_true",
68
+ help="Don't remove sections like References, Acknowledgements, etc."
69
+ )
70
+
71
+ parser.add_argument(
72
+ "--keep-footnotes",
73
+ action="store_true",
74
+ help="Don't remove footnote references"
75
+ )
76
+
77
+ parser.add_argument(
78
+ "--no-replacements",
79
+ action="store_true",
80
+ help="Don't perform text replacements"
81
+ )
82
+
83
+ parser.add_argument(
84
+ "--keep-inline-patterns",
85
+ action="store_true",
86
+ help="Don't remove inline patterns like citations"
87
+ )
88
+
89
+ parser.add_argument(
90
+ "--keep-empty-lines",
91
+ action="store_true",
92
+ help="Don't contract consecutive empty lines"
93
+ )
94
+
95
+ parser.add_argument(
96
+ "--no-crimp-linebreaks",
97
+ action="store_true",
98
+ help="Don't crimp linebreaks"
99
+ )
100
+
101
+ return parser.parse_args(args)
102
+
103
+
104
+ def main(args: Optional[List[str]] = None) -> int:
105
+ """Main entry point for the CLI."""
106
+ parsed_args = parse_args(args)
107
+
108
+ # Configure cleaner options based on arguments
109
+ options = CleanerOptions()
110
+ options.remove_short_lines = not parsed_args.keep_short_lines
111
+ options.min_line_length = parsed_args.min_line_length
112
+ options.remove_whole_lines = not parsed_args.keep_bad_lines
113
+ options.remove_sections = not parsed_args.keep_sections
114
+ options.remove_footnotes_in_text = not parsed_args.keep_footnotes
115
+ options.replace_within_lines = not parsed_args.no_replacements
116
+ options.remove_within_lines = not parsed_args.keep_inline_patterns
117
+ options.contract_empty_lines = not parsed_args.keep_empty_lines
118
+ options.crimp_linebreaks = not parsed_args.no_crimp_linebreaks
119
+
120
+ # Load patterns from custom config or use defaults
121
+ if parsed_args.config:
122
+ try:
123
+ patterns = CleaningPatterns.from_yaml(parsed_args.config)
124
+ except Exception as e:
125
+ print(f"Error loading custom configuration: {e}", file=sys.stderr)
126
+ return 1
127
+ else:
128
+ patterns = get_default_patterns()
129
+
130
+ # Initialize the cleaner with patterns and configured options
131
+ cleaner = MarkdownCleaner(patterns=patterns, options=options)
132
+
133
+ try:
134
+ # Clean the markdown file
135
+ output_file = cleaner.clean_markdown_file(
136
+ input_file=parsed_args.input_file,
137
+ output_path=parsed_args.output_dir,
138
+ output_file=parsed_args.output
139
+ )
140
+ print(f"Cleaned markdown saved to: {output_file}")
141
+ return 0
142
+ except Exception as e:
143
+ print(f"Error: {e}", file=sys.stderr)
144
+ return 1
145
+
146
+
147
+ if __name__ == "__main__":
148
+ sys.exit(main())
File without changes
@@ -0,0 +1,296 @@
1
+ sections_to_remove:
2
+ - |-
3
+ Acknowledge?ments?\s?$
4
+ - Declaration of Competing Interest
5
+ - Declaration of Conflicting Interests
6
+ - Funding
7
+ - Endnotes
8
+ - Credit authorship contribution statement
9
+ - |-
10
+ ACM\s?Reference\?Format
11
+ - Contributors
12
+ - CCS CONCEPTS
13
+ - pdf version of the entry
14
+ - |-
15
+ Correspondence\s?$
16
+ - Funding information
17
+ - ORCID
18
+ - RESEARCH ARTICLE
19
+ - Compliance with Ethical Standards
20
+ - License
21
+ - |-
22
+ Index\s?$
23
+ - |-
24
+ References\s?\.?$
25
+ - |-
26
+ \s?\d{1,2}\.\s*References\s?$
27
+ - |-
28
+ Bibliography\s?
29
+ - |-
30
+ ^\s?Notes\s?\:?\.?$
31
+ - Works Cited
32
+ - Author Biographies
33
+ - Author Biography
34
+ - About the author
35
+ - Oxford Scholarship Online
36
+ - University Press Scholarship Online
37
+ - Corresponding author
38
+ - |-
39
+ ^Appendix\*
40
+ - Contents
41
+ - Table of Contents
42
+ - List of Figures
43
+ - List of Tables
44
+ - List of Illustrations
45
+ - |-
46
+ ^\s?Keywords\s?\.?$
47
+ - Authors' Note
48
+ - |-
49
+ ^[a-zA-Z0-9]+-[a-zA-Z0-9]+-?[a-zA-Z0-9]+[^\s]*$
50
+ - |-
51
+ Dedication\:?$
52
+ - Citing this paper
53
+ - General rights
54
+ - Disclosure statement
55
+ - PLEASE SCROLL DOWN FOR ARTICLE
56
+ - About the Contributors
57
+ - List of Key Terms
58
+ - |-
59
+ Citations\:
60
+ - |-
61
+ Provided by\:
62
+ - |-
63
+ Published by\:
64
+ - Preface
65
+
66
+ # Patterns for inline text that should be removed
67
+ bad_inline_patterns:
68
+ - |-
69
+ \[\d{1,3}(?:\s*,\s*\d{1,3})*\]
70
+ - |-
71
+ \(see.*?\)
72
+ - |-
73
+ \(Figure\s*.*?\)
74
+ - |-
75
+ \(Table\s*.*?\)
76
+ - |-
77
+ \(Section\s*.*?\)
78
+ - |-
79
+ \(Chapter\s*.*?\)
80
+ - |-
81
+ \* 1 ,
82
+ - |-
83
+ \(p\.?\d{1,3}\)
84
+
85
+ # Patterns for entire lines that should be removed
86
+ bad_lines_patterns:
87
+ # Copyright and legal
88
+ - |-
89
+ (?m)^.*?\b[Aa]ll [Rr]ights [Rr]eserved\..*?$|.*?\b[Aa]ll [Rr]ights [Rr]eserved\.$
90
+ - |-
91
+ Copyright \d{4} by
92
+ - |-
93
+ ^#{1,3} ORIGINAL RESEARCH$
94
+ - |-
95
+ ^#{1,3} ORIGINAL ARTICLE$
96
+ - |-
97
+ ^[A-Z\s]+$
98
+ - |-
99
+ <!--.*?-->
100
+ - |-
101
+ ^- (\d{1,3})?\.
102
+ - |-
103
+ ^Received:
104
+ - |-
105
+ ^\(Received
106
+ - |-
107
+ Received.*?(\d{4}).*?(accepted|Accepted).*
108
+ - |-
109
+ doi:
110
+ - |-
111
+ 10\.\d{4,9}/[-._;()/:\w]+
112
+ - Digital Object Identifier
113
+ - |-
114
+ ^[Kk]eywords
115
+ - |-
116
+ ^Conflict of Interest
117
+ - ^Library of Congress
118
+ - ^Open Access
119
+ - ^OpenAccess
120
+ - ^Department of
121
+ - |-
122
+ ^[A-Z][a-zA-Z]+ University Press, Inc
123
+ - |-
124
+ ^Author\(s\):
125
+ - ^Acknowledgements
126
+ - ^Acknowledgments
127
+ - ^Funding Statement
128
+ - ^Table of Contents
129
+ - |-
130
+ ^Source:
131
+ - |-
132
+ ^Published by
133
+ - |-
134
+ ^CCS Concepts
135
+ - ISSN
136
+ - ISBN
137
+ - |-
138
+ arxiv:
139
+ - JSTOR
140
+ - Contributed Paper
141
+ - |-
142
+ ^Fig\.
143
+ - |-
144
+ ^\s?Figure\s?\d{1,2}\s?[.:]
145
+ - |-
146
+ ^\s?Table\s?\d{1,2}\s?[.:]
147
+ - ^First published
148
+ - |-
149
+ National Science Foundation\s*\(NSF\).*?[Gg]rant.*?\d{4,}
150
+ - |-
151
+ [Gg]rant\s*[Nn]umber\s*\d{4,}
152
+ - |-
153
+ [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}
154
+ - |-
155
+ \{\s*[\w,\s]+\s*\}\s*@\s*[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}
156
+ - |-
157
+ https?://?[^\s]+
158
+ - ©
159
+ - ®
160
+ - ™
161
+ - |-
162
+ c \d{4}
163
+ - |-
164
+ ^\d{1,3}\s?.\s?[Ss]ee
165
+ - |-
166
+ ^\-\s?\d{1,3}\s?.\s?[Ss]ee
167
+ - |-
168
+ ^\d{1,2}[A-Za-z]+
169
+ - |-
170
+ ^#{1,3}[\d\s]+$
171
+ - |-
172
+ ^[^/]*(/[^/]*){5,}
173
+ - †
174
+ - ‡
175
+ - |-
176
+ ^\d{1,3}.*supra note
177
+ - |-
178
+ ^\d{1,3}.*\(\d{4}\)
179
+ - |-
180
+ ^\d{1,3}.*cf
181
+ - |-
182
+ ^\d{1,3}.\s?Id\.
183
+ - |-
184
+ (?:.*?GLYPH\&lt){3,}.*
185
+ - on previous versions of this
186
+ - |-
187
+ ^We are grateful for
188
+ - |-
189
+ ^I am grateful
190
+ - |-
191
+ ^Subject:
192
+ - open access article under the terms of the Creative Commons
193
+ - |-
194
+ Key words:
195
+ - |-
196
+ Published online:
197
+ - |-
198
+ Correspondence:
199
+ - |-
200
+ Print Publication Date:
201
+ - |-
202
+ Accepted:
203
+ - |-
204
+ This article was downloaded by:
205
+ - This content downloaded from
206
+ - |-
207
+ Registered office:
208
+ - |-
209
+ DATE DOWNLOADED:
210
+ - NBER working papers are circulated
211
+ - FirstView Article
212
+ - Published in
213
+ - Published online
214
+ - Typeset by
215
+ - All rights reserved
216
+ - No portion of this publication may be reproduced
217
+ - authors have asserted their rights to be identified as the authors
218
+ - material in this work has been asserted in accordance
219
+ - imprint of Macmillan Publishers
220
+ - A catalogue record for this book is available
221
+ - Your use of this HeinOnline
222
+ - obtain permission to use this article
223
+ - This article is published with open access
224
+ - Links to third party websites
225
+ - Registered in England and Wales
226
+ - |-
227
+ \*I thank
228
+ - |-
229
+ \*We thank
230
+ - |-
231
+ \*For comments
232
+ - |-
233
+ \*I would like to thank
234
+ - |-
235
+ \*We would like to thank
236
+ - |-
237
+ \*Thanks to
238
+ - |-
239
+ \*\s?This article is
240
+ - I am grateful for
241
+ - Oxford University Press is a department of
242
+ - a registered trademark of
243
+ - Published in the United States
244
+ - Printed in the United States
245
+ - The paper used in this publication
246
+ - Trademark notice
247
+ - 1 Department of
248
+ - catalogue recordfor this publication
249
+ - An earlier version of this paper was
250
+ - Springer International Publishing AG
251
+ - part of Springer Nature
252
+ - This work is subject to copyright.
253
+ - permission to reuse the copyright
254
+ - distributed under the terms of a Creative Commons
255
+ - |-
256
+ , [A-Z]{2} \d{5}(?:-\d{4})?,
257
+
258
+ # Footnotes
259
+ # Pattern matches:
260
+ # 1. A period
261
+ # 2. Optional space
262
+ # 3. 1-3 digits
263
+ # 4. Optionally followed by comma and space and more 1-3 digit numbers
264
+ # 5. This pattern repeats for any number of comma-separated numbers
265
+ # 6. Followed by either end of string, whitespace, or newline
266
+ footnote_patterns:
267
+ - |-
268
+ \.\s?(\d{1,3}(?:,\s*\d{1,3})*?)(?=\s|$)
269
+
270
+ replacements:
271
+ 'GLYPH&lt;28&gt;': 'fi'
272
+ 'GLYPH&lt;29&gt;': 'fl'
273
+ 'GLYPH&lt;3&gt;': '*'
274
+ 'GLYPH&lt;21&gt;': '-'
275
+ 'GLYPH&lt;22&gt;': '—'
276
+ 'GLYPH&lt;15&gt;': '-'
277
+ 'GLYPH&lt;129&gt;': '-'
278
+ 'GLYPH&lt;133&gt;': 'fi'
279
+ 'GLYPH<28>': 'fi'
280
+ 'GLYPH<29>': 'fl'
281
+ 'GLYPH<3>': '*'
282
+ 'GLYPH<21>': '-'
283
+ 'GLYPH<22>': '—'
284
+ 'GLYPH<15>': '•'
285
+ '/uniFB01': 'fi'
286
+ '/uniFB03': 'ffi'
287
+ '/uniFB00': 'ff'
288
+ '/uniFB02': 'fl'
289
+ 'GLYPH<146>': 'Š'
290
+ 'GLYPH<228>': 'ä'
291
+ 'GLYPH<252>': 'ü'
292
+ 'GLYPH<237>': 'í'
293
+ 'GLYPH<2>': 'Š'
294
+ '/ornm58.ornm': ''
295
+ '/ornm69.ornm': ''
296
+ '&amp;': '&'
@@ -0,0 +1,72 @@
1
+ from pathlib import Path
2
+ import yaml
3
+ import re
4
+ from typing import List, Pattern, Optional, Dict
5
+ from dataclasses import dataclass
6
+ import logging
7
+
8
+ _logger = logging.getLogger(__name__)
9
+
10
+ @dataclass
11
+ class CleaningPatterns:
12
+ """Container for document cleaning patterns."""
13
+ sections_to_remove: List[str]
14
+ bad_inline_patterns: List[Pattern]
15
+ bad_lines_patterns: List[Pattern]
16
+ footnote_patterns: List[Pattern]
17
+ replacements: Dict[Pattern | str, str]
18
+
19
+ @classmethod
20
+ def from_yaml(cls, yaml_path: Path) -> 'CleaningPatterns':
21
+ """
22
+ Create a CleaningPatterns instance from a YAML file.
23
+
24
+ Args:
25
+ yaml_path: Path to the YAML configuration file
26
+
27
+ Returns:
28
+ CleaningPatterns instance with compiled regex patterns
29
+ """
30
+ try:
31
+ with open(yaml_path, 'r', encoding='utf-8') as f:
32
+ config = yaml.safe_load(f)
33
+
34
+ def compile_pattern(pattern: str) -> Pattern:
35
+ """Safely compile a regex pattern."""
36
+ if not isinstance(pattern, str):
37
+ raise TypeError(f"Pattern must be a string, got {type(pattern)}: {pattern}")
38
+ try:
39
+ return re.compile(pattern, re.IGNORECASE)
40
+ except re.error as e:
41
+ _logger.error(f"Invalid regex pattern '{pattern}': {e}")
42
+ raise
43
+
44
+ # Ensure patterns are strings before compiling
45
+ sections = [str(p).strip() for p in config.get('sections_to_remove', [])]
46
+ inline_patterns = [str(p).strip() for p in config.get('bad_inline_patterns', [])]
47
+ line_patterns = [str(p).strip() for p in config.get('bad_lines_patterns', [])]
48
+ footnotes = [str(p).strip() for p in config.get('footnote_patterns', [])]
49
+ replace = {str(k).strip(): str(v).strip() for k, v in config.get('replacements', {}).items()}
50
+
51
+ return cls(
52
+ sections_to_remove=sections,
53
+ bad_inline_patterns=[compile_pattern(p) for p in inline_patterns],
54
+ bad_lines_patterns=[compile_pattern(p) for p in line_patterns],
55
+ footnote_patterns=[compile_pattern(p) for p in footnotes],
56
+ replacements=replace # just taking this a str dict, no patterns to compile
57
+ )
58
+ except Exception as e:
59
+ _logger.error(f"Error loading cleaning patterns from {yaml_path}: {e}")
60
+ raise
61
+
62
+
63
+ _cached_patterns: Optional[CleaningPatterns] = None
64
+
65
+ def get_default_patterns() -> CleaningPatterns:
66
+ """Load the default cleaning patterns from the package's configuration file."""
67
+ global _cached_patterns
68
+ if _cached_patterns is None:
69
+ config_dir = Path(__file__).parent
70
+ yaml_path = config_dir / 'default_cleaning_patterns.yaml'
71
+ _cached_patterns = CleaningPatterns.from_yaml(yaml_path)
72
+ return _cached_patterns
@@ -0,0 +1,372 @@
1
+ from pathlib import Path
2
+ from dataclasses import dataclass
3
+ from typing import Pattern, Optional
4
+ import logging
5
+ import re
6
+ import ftfy
7
+ from markdowncleaner.config.loader import get_default_patterns, CleaningPatterns
8
+
9
+ _logger = logging.getLogger(__name__)
10
+
11
+ @dataclass
12
+ class CleanerOptions:
13
+ """Countainer for Cleaner options"""
14
+ remove_short_lines: bool = True
15
+ min_line_length: int = 70
16
+ remove_whole_lines: bool = True
17
+ remove_sections: bool = True
18
+ remove_footnotes_in_text: bool = True
19
+ replace_within_lines: bool = True
20
+ remove_within_lines: bool = True
21
+ contract_empty_lines: bool = True
22
+ crimp_linebreaks: bool = True
23
+
24
+
25
+ class MarkdownCleaner:
26
+ """Class to handle markdown document cleaning operations."""
27
+
28
+ def __init__(self, patterns: CleaningPatterns = None, options: Optional[CleanerOptions] = None):
29
+ """
30
+ Initialize the cleaner with patterns.
31
+
32
+ Args:
33
+ patterns: CleaningPatterns instance, or None to use defaults
34
+ """
35
+ self.patterns = patterns or get_default_patterns()
36
+ self.options = options or CleanerOptions()
37
+
38
+ def clean_markdown_file(self,
39
+ input_file: Path,
40
+ output_path: Optional[Path] = None,
41
+ output_file: Optional[Path | str] = None) -> Path:
42
+ """
43
+ Clean a markdown file using the configured patterns.
44
+
45
+ Args:
46
+ input_file: Path to the input markdown file
47
+ output_path: Optional directory to save the cleaned file
48
+ output_file: Optional path to save the cleaned file directly (overrides output_path)
49
+
50
+ Returns:
51
+ Path: The path to the cleaned output file
52
+ """
53
+ # Read the content of the input file
54
+ with open(input_file, 'r', encoding='utf-8') as file:
55
+ cleaned_content = file.read()
56
+
57
+ # Apply cleaning operations
58
+ cleaned_content = self.clean_markdown_string(cleaned_content)
59
+
60
+ if output_file is not None:
61
+ cleaned_filepath = Path(output_file)
62
+ # Ensure parent directory exists
63
+ cleaned_filepath.parent.mkdir(parents=True, exist_ok=True)
64
+ elif output_path is not None:
65
+ # Determine the output filepath
66
+ output_path.mkdir(parents=True, exist_ok=True)
67
+ cleaned_filepath = output_path / input_file.name
68
+ else: # both output_path and output_file are None
69
+ # Generate a new filename with "_cleaned" suffix
70
+ cleaned_filename = f"{input_file.stem}_cleaned{input_file.suffix}"
71
+ cleaned_filepath = input_file.parent / cleaned_filename
72
+ # Ensure parent directory exists (might be unnecessary if same as input, but added for safety)
73
+ cleaned_filepath.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ # Write the cleaned content
76
+ with open(cleaned_filepath, 'w', encoding='utf-8') as file:
77
+ file.write(cleaned_content)
78
+
79
+ _logger.info(f"Cleaned file saved to: {cleaned_filepath}")
80
+
81
+ return cleaned_filepath
82
+
83
+ def clean_markdown_string(self, content: str) -> str:
84
+ """Apply all cleaning operations to the content."""
85
+
86
+ # Apply all default ftfy fixes if mojibake is detected
87
+ if ftfy.is_bad(content):
88
+ content = ftfy.fix_text(content)
89
+
90
+ # Reduce two or more subsequent spaces to a single space
91
+ content = re.sub(r' {2,}', ' ', content)
92
+
93
+ # Normalize quotes
94
+ content = self._normalize_quotation_symbols(content)
95
+
96
+ # Remove lines shorter than min_line_length (default: 70 characters)
97
+ if self.options.remove_short_lines:
98
+ content = self._remove_short_lines(content, self.options.min_line_length)
99
+ # Clean out "bad" lines
100
+ if self.options.remove_whole_lines:
101
+ content = self._remove_whole_lines(content, self.patterns.bad_lines_patterns)
102
+
103
+ # Remove unwanted sections
104
+ if self.options.remove_sections:
105
+ for title in self.patterns.sections_to_remove:
106
+ content = self._remove_sections(content, title)
107
+
108
+ # Replace strings for string
109
+ if self.options.replace_within_lines:
110
+ for k, v in self.patterns.replacements.items():
111
+ content = self._replace_within_lines(content, k, v)
112
+
113
+ # Replace footnote pattern (numbers at end of sentence) with '.'
114
+ if self.options.remove_footnotes_in_text:
115
+ content = self._replace_within_lines(content, self.patterns.footnote_patterns, '.')
116
+
117
+ # Remove remaining unwanted inline patterns (some may have been replaced by replacements)
118
+ if self.options.remove_within_lines:
119
+ content = self._replace_within_lines(content, self.patterns.bad_inline_patterns, '')
120
+
121
+ # Clean up formatting
122
+ if self.options.crimp_linebreaks:
123
+ content = self._crimp_linebreaks(content)
124
+ if self.options.contract_empty_lines:
125
+ content = self._contract_empty_lines(content)
126
+
127
+ return content
128
+
129
+ def _normalize_quotation_symbols(self, text: str) -> str:
130
+ """
131
+ Normalizes quotation symbols in the input text.
132
+
133
+ Args:
134
+ text (str): Input text to clean
135
+ Returns:
136
+ str: Text with all single and double quotation symbols replaced with standard ones.
137
+ """
138
+ double_quotes = [
139
+ "«",
140
+ "‹",
141
+ "»",
142
+ "›",
143
+ "„",
144
+ "“",
145
+ "‟",
146
+ "”",
147
+ "❝",
148
+ "❞",
149
+ "❮",
150
+ "❯",
151
+ "〝",
152
+ "〞",
153
+ "〟",
154
+ """,
155
+ ]
156
+ single_quotes = ["‘", "‛", "’", "❛", "❜", "`", "´", "‘", "’"]
157
+
158
+ double_quotes_regex = re.compile("|".join(double_quotes))
159
+ single_quotes_regex = re.compile("|".join(single_quotes))
160
+
161
+ text = single_quotes_regex.sub("'", text)
162
+ text = double_quotes_regex.sub('"', text)
163
+
164
+ return text
165
+
166
+ def _replace_within_lines(self, text: str, patterns: str | Pattern | list[str | Pattern], replacement: str = '') -> str:
167
+ """
168
+ Removes multiple patterns from text, applying them sequentially.
169
+
170
+ Args:
171
+ text (str): Input text to clean
172
+ patterns (str | Pattern | list): Single regex pattern or list of regex patterns to remove
173
+ replacement (str, optional): String to replace the matched patterns with. Defaults to ''.
174
+
175
+ Returns:
176
+ str: Text with all patterns removed or replaced
177
+ """
178
+ # Ensure patterns is a list, converting single pattern to a list if needed
179
+ if not isinstance(patterns, list):
180
+ patterns = [patterns]
181
+
182
+ cleaned_text = text
183
+ for pattern in patterns:
184
+ # Make sure each pattern is a compiled regex
185
+ if isinstance(pattern, str):
186
+ pattern = re.compile(pattern)
187
+
188
+ cleaned_text = pattern.sub(replacement, cleaned_text)
189
+ return cleaned_text
190
+
191
+ def _remove_short_lines(self, multiline_string: str, length: int = 70) -> str:
192
+ """
193
+ Remove lines from a multiline string that are shorter than a specified length.
194
+
195
+ Args:
196
+ multiline_string: The input string to clean
197
+ length: The minimum length of lines to keep
198
+
199
+ Returns:
200
+ Cleaned string with matching lines removed
201
+ """
202
+
203
+ # Split the content into lines
204
+ lines = multiline_string.splitlines()
205
+
206
+ # Filter out lines that are shorter than length but that are neither empty nor start with '#'
207
+ filtered_lines = []
208
+ for line in lines:
209
+ if not line.strip() == '' and not line.startswith('#') and len(line) < length:
210
+ continue
211
+ filtered_lines.append(line)
212
+
213
+ # Join the remaining lines back into a single string
214
+ return '\n'.join(filtered_lines)
215
+
216
+ def _remove_whole_lines(self, multiline_string: str, patterns: str | Pattern | list[str | Pattern]) -> str:
217
+ """
218
+ Remove lines from a multiline string that match specified regex pattern(s).
219
+
220
+ Args:
221
+ multiline_string: The input string to clean
222
+ patterns: A single regex pattern (str or compiled Pattern) or a list of patterns
223
+
224
+ Returns:
225
+ Cleaned string with matching lines removed
226
+ """
227
+
228
+ # Split the content into lines
229
+ lines = multiline_string.splitlines()
230
+
231
+ # Make sure patterns is a list
232
+ if not isinstance(patterns, list):
233
+ patterns = [patterns]
234
+ # Filter out lines that match any of the patterns, except empty lines
235
+ filtered_lines = []
236
+ for line in lines:
237
+ if line.strip() == '': # Keep empty lines
238
+ filtered_lines.append(line)
239
+ continue
240
+ if any(pattern.search(line) for pattern in patterns):
241
+ continue
242
+ filtered_lines.append(line)
243
+
244
+ # Join the remaining lines back into a single string
245
+ return '\n'.join(filtered_lines)
246
+
247
+ def _contract_empty_lines(self, multiline_string: str) -> str:
248
+ """Contract two or more consecutive empty lines from a multiline string."""
249
+ lines = multiline_string.splitlines()
250
+ result = []
251
+ prev_empty = False
252
+
253
+ for line in lines:
254
+ is_empty = not line.strip()
255
+ if not (is_empty and prev_empty):
256
+ result.append(line)
257
+ prev_empty = is_empty
258
+
259
+ return '\n'.join(result)
260
+
261
+ def _remove_sections(self, markdown_text: str, section_pattern: str) -> str:
262
+ """
263
+ Removes a first or second level section from a markdown document if its title
264
+ matches the given regular expression pattern.
265
+
266
+ Args:
267
+ markdown_text (str): The input markdown text
268
+ section_pattern (str): Regular expression pattern to match the section title
269
+
270
+ Returns:
271
+ str: The markdown text with the matching section removed
272
+ """
273
+ # Compile the pattern with IGNORECASE flag
274
+ pattern = re.compile(section_pattern, re.IGNORECASE)
275
+
276
+ # Split the markdown into lines
277
+ lines = markdown_text.splitlines()
278
+ result_lines = []
279
+
280
+ i = 0
281
+ while i < len(lines):
282
+ line = lines[i].strip()
283
+
284
+ # Check if line is a first or second level heading
285
+ if line.startswith(('#', '##')):
286
+ # Extract the heading text
287
+ heading_text = line.lstrip('#').strip() #.lower()
288
+
289
+ # Check if heading matches the pattern
290
+ if pattern.search(heading_text):
291
+ # Skip this heading and find the end of its section
292
+ i += 1
293
+ section_level = line.count('#')
294
+
295
+ # Continue until we find a heading of same or higher level, or end of document
296
+ while i < len(lines):
297
+ next_line = lines[i].strip()
298
+ if next_line.startswith('#'):
299
+ next_level = next_line.count('#')
300
+ if next_level <= section_level:
301
+ break
302
+ i += 1
303
+ continue
304
+
305
+ result_lines.append(lines[i])
306
+ i += 1
307
+
308
+ # Return the modified markdown, preserving original line endings
309
+ return '\n'.join(result_lines)
310
+
311
+ def _crimp_linebreaks(self, markdown_text: str) -> str:
312
+ """
313
+ Fix line break errors in markdown text converted from PDF.
314
+
315
+ Args:
316
+ markdown_text (str): Input markdown text with potential line break errors
317
+
318
+ The function handles two cases:
319
+ 1. Hyphenated words split across lines (even with one empty line in between)
320
+ 2. Paragraphs incorrectly split by empty lines when a line ends with a letter
321
+
322
+ Returns:
323
+ str: Text with all patterns removed
324
+ """
325
+
326
+ lines = markdown_text.splitlines()
327
+ result_lines = []
328
+ i = 0
329
+
330
+ while i < len(lines):
331
+ current_line = lines[i].strip()
332
+
333
+ # Try to join as many consecutive lines as possible
334
+ while True:
335
+ joined = False
336
+
337
+ # Case 1: Handle hyphenated words
338
+ if current_line.endswith('-'):
339
+ j = i + 1
340
+ while j < len(lines) and not lines[j].strip():
341
+ j += 1
342
+
343
+ if j < len(lines) and lines[j].strip() and lines[j].strip()[0].islower():
344
+ current_line = current_line[:-1] + lines[j].strip()
345
+ i = j # Update i to the last joined line
346
+ joined = True
347
+ continue # Skip to next join check
348
+
349
+ # Case 2: Handle paragraph merging
350
+ if not current_line.startswith('#') and current_line and (current_line[-1].isalpha() or current_line[-1] in ',;\'\"'):
351
+ _logger.debug(f'Crimping line:... {current_line[-50:]}')
352
+ j = i + 1
353
+ while j < len(lines) and not lines[j].strip():
354
+ j += 1
355
+
356
+ if j < len(lines) and lines[j].strip() and \
357
+ not lines[j].strip().startswith('#') and \
358
+ not lines[j].strip().startswith('*') and \
359
+ not lines[j].strip().startswith('-'):
360
+ current_line = current_line + ' ' + lines[j].strip()
361
+ i = j # Update i to the last joined line
362
+ joined = True # noqa: F841
363
+ continue # Skip to next join check
364
+
365
+ # If no joins were made, break the loop
366
+ break
367
+
368
+ # Add the fully processed line to results
369
+ result_lines.append(current_line)
370
+ i += 1 # Move to the next line
371
+
372
+ return '\n'.join(result_lines)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Johannes Himmelreich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.2
2
+ Name: markdowncleaner
3
+ Version: 0.1.1
4
+ Summary: A tool for cleaning and formatting markdown documents
5
+ Author-email: Johannes Himmelreich <jrhimmel@syr.edu>
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/josk0/markdowncleaner
8
+ Project-URL: Issues, https://github.com/josk0/markdowncleaner/issues
9
+ Keywords: markdown,cleaning,formatting,text processing
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
20
+ Classifier: Topic :: Utilities
21
+ Classifier: Development Status :: 4 - Beta
22
+ Classifier: Intended Audience :: Science/Research
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: pyyaml>=6.0
27
+ Requires-Dist: ftfy>=6.0.3
28
+
29
+ # markdowncleaner
30
+
31
+ A simple Python tool for cleaning and formatting markdown documents. Default configuration with regex patterns for PDFs of academic papers that have been converted to markdown.
32
+
33
+ ## Description
34
+
35
+ `markdowncleaner` helps you clean up markdown files by removing unwanted content such as:
36
+ - References, bibliographies, and citations
37
+ - Footnotes and endnotes
38
+ - Copyright notices and legal disclaimers
39
+ - Acknowledgements and funding information
40
+ - Author information and contact details
41
+ - Specific patterns like DOIs, URLs, and email addresses
42
+ - Short lines and excessive whitespace
43
+
44
+ This tool is particularly useful for processing academic papers, books, or any markdown document that needs formatting cleanup.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install markdowncleaner
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Basic Usage
55
+
56
+ ```python
57
+ from markdowncleaner import MarkdownCleaner
58
+ from pathlib import Path
59
+
60
+ # Create a cleaner with default patterns
61
+ cleaner = MarkdownCleaner()
62
+
63
+ # Clean a markdown file
64
+ result_path = cleaner.clean_markdown_file(Path("input.md"))
65
+
66
+ # Clean a markdown string
67
+ text = "# Title\nSome content here. [1]\n\nReferences\n1. Citation"
68
+ cleaned_text = cleaner.clean_markdown_string(text)
69
+ print(cleaned_text)
70
+ ```
71
+
72
+ ### Customizing Cleaning Options
73
+
74
+ ```python
75
+ from markdowncleaner import MarkdownCleaner, CleanerOptions
76
+
77
+ # Create custom options
78
+ options = CleanerOptions()
79
+ options.remove_short_lines = True
80
+ options.min_line_length = 50 # custom minimum line length
81
+ options.remove_footnotes_in_text = True
82
+ options.contract_empty_lines = True
83
+
84
+ # Initialize cleaner with custom options
85
+ cleaner = MarkdownCleaner(options=options)
86
+
87
+ # Use the cleaner as before
88
+ ```
89
+
90
+ ### Custom Cleaning Patterns
91
+
92
+ You can also provide custom cleaning patterns:
93
+
94
+ ```python
95
+ from markdowncleaner import MarkdownCleaner, CleaningPatterns
96
+ from pathlib import Path
97
+
98
+ # Load custom patterns from a YAML file
99
+ custom_patterns = CleaningPatterns.from_yaml(Path("my_patterns.yaml"))
100
+
101
+ # Initialize cleaner with custom patterns
102
+ cleaner = MarkdownCleaner(patterns=custom_patterns)
103
+ ```
104
+
105
+ ## Configuration
106
+
107
+ The default cleaning patterns are defined in `default_cleaning_patterns.yaml` and include:
108
+
109
+ - **Sections to Remove**: Acknowledgements, References, Bibliography, etc.
110
+ - **Bad Inline Patterns**: Citations, figure references, etc.
111
+ - **Bad Lines Patterns**: Copyright notices, DOIs, URLs, etc.
112
+ - **Footnote Patterns**: Footnote references in text that fit the pattern '.1'
113
+ - **Replacements**: Various character replacements for PDF parsing errors
114
+
115
+ ## Options
116
+
117
+ - `remove_short_lines`: Remove lines shorter than `min_line_length` (default: 70 characters)
118
+ - `remove_whole_lines`: Remove lines matching specific patterns
119
+ - `remove_sections`: Remove entire sections based on section headings
120
+ - `remove_footnotes_in_text`: Remove footnote references
121
+ - `replace_within_lines`: Replace specific patterns within lines
122
+ - `remove_within_lines`: Remove specific patterns within lines
123
+ - `contract_empty_lines`: Normalize whitespace
124
+ - `crimp_linebreaks`: Improve line break formatting
125
+
126
+ ## License
127
+
128
+ MIT License
129
+
130
+ ## Contributing
131
+
132
+ Contributions are welcome! Please feel free to submit a Pull Request.
@@ -0,0 +1,12 @@
1
+ markdowncleaner/__init__.py,sha256=5P_nDL6T2WXoX2jVEtf-3fpGb-rEG1A_Bo-H0-upQFs,140
2
+ markdowncleaner/cli.py,sha256=qpqEl3AL0ekQe9JoMljuEZQQlMRnEH9kFz7np7wF_64,4356
3
+ markdowncleaner/markdowncleaner.py,sha256=wLWOBw_Oy1buw1Av7KvDDaN1V9w5gqvM27CMS9SzF3c,14150
4
+ markdowncleaner/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ markdowncleaner/config/default_cleaning_patterns.yaml,sha256=VI2tDtgHelJbEMUVR9dUUlXqagfsIpS86_Zss-I6Qsg,6005
6
+ markdowncleaner/config/loader.py,sha256=SX9ZgsGFV-25sFCeTy9XW5UQARz3EeaUhrVHu2Y-XeU,2927
7
+ markdowncleaner-0.1.1.dist-info/LICENSE,sha256=yH2dxfGn0hRK5hPAnNFarbaFq1jx8wrPdqy-nsMi2II,1076
8
+ markdowncleaner-0.1.1.dist-info/METADATA,sha256=8SXdYIt669JCe07MsNBG3zx8vr2-_zqt5yRthZO-duE,4296
9
+ markdowncleaner-0.1.1.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
10
+ markdowncleaner-0.1.1.dist-info/entry_points.txt,sha256=C_FJkZY4OeKfdTacBhqh9hzjZEk8gQJGzGSI8qLr-lU,61
11
+ markdowncleaner-0.1.1.dist-info/top_level.txt,sha256=dMBEsk9DuO55cKIbeM4Ih2e0Y5aP6AfcKEYo2SoyFs0,16
12
+ markdowncleaner-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.8.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ markdowncleaner = markdowncleaner.cli:main
@@ -0,0 +1 @@
1
+ markdowncleaner