flowmark 0.1.0__tar.gz

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.
flowmark-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Joshua Levy
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,53 @@
1
+ Metadata-Version: 2.3
2
+ Name: flowmark
3
+ Version: 0.1.0
4
+ Summary: Better line wrapping and formatting for plaintext and Markdown
5
+ License: MIT
6
+ Author: Joshua Levy
7
+ Author-email: joshua@cal.berkeley.edu
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: marko (>=2.1.2,<3.0.0)
16
+ Requires-Dist: regex (>=2024.11.6,<2025.0.0)
17
+ Project-URL: Repository, https://github.com/jlevy/flowmark
18
+ Description-Content-Type: text/markdown
19
+
20
+ # flowmark
21
+
22
+ Flowmark is a new Python implementation of text line wrapping and filling.
23
+
24
+ It can be used as a more flexible alternative to Python's
25
+ [`textwrap`](https://docs.python.org/3/library/textwrap.html) with a few more features,
26
+ such as full customizability of initial and subsequent indentation strings, and more
27
+ control over when to split words, so it won't break lines within HTML tags.
28
+
29
+ It also combines line wrapping with support for Markdown and offers Markdown
30
+ auto-formatting, like [markdownfmt](https://github.com/shurcooL/markdownfmt), also with
31
+ controllable line wrapping options.
32
+
33
+ One key use case is to normalize Markdown in a standard, readable way that makes diffs
34
+ easy to read and use on GitHub.
35
+ This can be useful for documentation workflows and also to compare LLM outputs that are
36
+ Markdown.
37
+
38
+ Finally, it has options to use heuristics to split on sentences, which can make diffs
39
+ much more readable. (For an example of this, look at the source to this readme file.)
40
+
41
+ It aims to be small and simple and have only a few dependencies, currently only
42
+ [`marko`](https://github.com/frostming/marko) and
43
+ [`regex`](https://pypi.org/project/regex/).
44
+
45
+ This is a new and simple package (previously I'd implemented something like this
46
+ [for Atom](https://github.com/jlevy/atom-flowmark)) but I plan to add more support for
47
+ command line usage and VSCode/Cursor auto-formatting in the future.
48
+
49
+ * * *
50
+
51
+ *This project was built from
52
+ [simple-modern-poetry](https://github.com/jlevy/simple-modern-poetry).*
53
+
@@ -0,0 +1,33 @@
1
+ # flowmark
2
+
3
+ Flowmark is a new Python implementation of text line wrapping and filling.
4
+
5
+ It can be used as a more flexible alternative to Python's
6
+ [`textwrap`](https://docs.python.org/3/library/textwrap.html) with a few more features,
7
+ such as full customizability of initial and subsequent indentation strings, and more
8
+ control over when to split words, so it won't break lines within HTML tags.
9
+
10
+ It also combines line wrapping with support for Markdown and offers Markdown
11
+ auto-formatting, like [markdownfmt](https://github.com/shurcooL/markdownfmt), also with
12
+ controllable line wrapping options.
13
+
14
+ One key use case is to normalize Markdown in a standard, readable way that makes diffs
15
+ easy to read and use on GitHub.
16
+ This can be useful for documentation workflows and also to compare LLM outputs that are
17
+ Markdown.
18
+
19
+ Finally, it has options to use heuristics to split on sentences, which can make diffs
20
+ much more readable. (For an example of this, look at the source to this readme file.)
21
+
22
+ It aims to be small and simple and have only a few dependencies, currently only
23
+ [`marko`](https://github.com/frostming/marko) and
24
+ [`regex`](https://pypi.org/project/regex/).
25
+
26
+ This is a new and simple package (previously I'd implemented something like this
27
+ [for Atom](https://github.com/jlevy/atom-flowmark)) but I plan to add more support for
28
+ command line usage and VSCode/Cursor auto-formatting in the future.
29
+
30
+ * * *
31
+
32
+ *This project was built from
33
+ [simple-modern-poetry](https://github.com/jlevy/simple-modern-poetry).*
@@ -0,0 +1,90 @@
1
+ [tool.poetry]
2
+ name = "flowmark"
3
+ # Keep this a dev version, as the dynamic versioning plugin is used for actual release versions:
4
+ version = "0.1.0"
5
+ description = "Better line wrapping and formatting for plaintext and Markdown"
6
+ authors = ["Joshua Levy <joshua@cal.berkeley.edu>"]
7
+ readme = "README.md"
8
+ license = "MIT"
9
+ repository = "https://github.com/jlevy/flowmark"
10
+ requires-poetry = ">=2.0"
11
+
12
+ # Useful plugins:
13
+ # https://github.com/python-poetry/poetry-plugin-shell
14
+ # https://github.com/mtkennerly/poetry-dynamic-versioning
15
+ # https://github.com/MousaZeidBaker/poetry-plugin-up
16
+ [tool.poetry.requires-plugins]
17
+ poetry-plugin-shell = "^1.0.1"
18
+ poetry-dynamic-versioning = "^1.7.0"
19
+ poetry-plugin-up = "^0.9.0"
20
+
21
+ [tool.poetry.dependencies]
22
+ python = "^3.10"
23
+ marko = "^2.1.2"
24
+ regex = "^2024.11.6"
25
+
26
+ [tool.poetry.group.dev.dependencies]
27
+ black = "^24.10.0"
28
+ pytest = "^8.3.4"
29
+ ruff = "^0.4.10"
30
+ usort = "^1.0.8.post1"
31
+ mypy = "^1.15.0"
32
+ codespell = "^2.4.1"
33
+ rich = "^13.9.4"
34
+
35
+ [build-system]
36
+ requires = ["poetry-core"]
37
+ build-backend = "poetry.core.masonry.api"
38
+
39
+ # Auto-generated version for builds based on git tag or commit.
40
+ #
41
+ # Preferred styles of versioning (PEP 440 compliant):
42
+ # 1.2.3 (stable release)
43
+ # 1.2.3a4 (pre-release, no dash or dots)
44
+ # 1.2.3rc1 (release candidate)
45
+ # 1.2.3.dev4+abcdef12 (development build, .dev for dev releases and +commit hash)
46
+ #
47
+ # Tags with a "v" prefix (e.g., v1.2.3) or additional dots (e.g., v1.2.3.rc1) are recognized
48
+ # but normalized to the preferred style for distribution.
49
+ # See: https://sam.hooke.me/note/2023/08/poetry-automatically-generated-package-version-from-git-commit/
50
+ [tool.poetry-dynamic-versioning]
51
+ enable = false
52
+ vcs = "git"
53
+ pattern = "^v?(?P<base>\\d+\\.\\d+\\.\\d+)(-?((?P<stage>[a-zA-Z]+)\\.?(?P<revision>\\d+)?))?"
54
+ format-jinja = """{%- if distance == 0 -%}
55
+ {{- base -}}
56
+ {%- if stage -%}
57
+ {{ stage }}{{ revision | default("") }}
58
+ {%- endif -%}
59
+ {%- else -%}
60
+ {{- base }}.dev{{ distance }}+{{commit}}
61
+ {%- endif -%}"""
62
+
63
+ [tool.black]
64
+ line-length = 88
65
+
66
+ [tool.ruff]
67
+ line-length = 88
68
+
69
+ [tool.ruff.lint]
70
+ ignore = ["E402", "E731", "E712"]
71
+
72
+ [tool.mypy]
73
+ disable_error_code = [
74
+ "import-untyped",
75
+ ]
76
+
77
+ [tool.codespell]
78
+ # ignore-words-list = "foo,bar"
79
+ # skip = "foo.py,bar.py"
80
+
81
+ [tool.pytest.ini_options]
82
+ python_files = ["*.py"]
83
+ python_classes = ["Test*"]
84
+ python_functions = ["test_*"]
85
+ testpaths = [
86
+ "src",
87
+ "tests",
88
+ ]
89
+ norecursedirs = []
90
+ filterwarnings = []
@@ -0,0 +1,19 @@
1
+ __all__ = (
2
+ "fill_text",
3
+ "fill_markdown",
4
+ "normalize_markdown",
5
+ "wrap_paragraph",
6
+ "wrap_paragraph_lines",
7
+ "wrap_lines_to_width",
8
+ "wrap_lines_using_sentences",
9
+ "Wrap",
10
+ )
11
+
12
+ from .markdown_filling import (
13
+ fill_markdown,
14
+ normalize_markdown,
15
+ wrap_lines_to_width,
16
+ wrap_lines_using_sentences,
17
+ )
18
+ from .text_filling import fill_text, Wrap
19
+ from .text_wrapping import wrap_paragraph, wrap_paragraph_lines
@@ -0,0 +1,419 @@
1
+ """
2
+ Auto-formatting of Markdown text.
3
+
4
+ This is similar to what is offered by
5
+ [markdownfmt](https://github.com/shurcooL/markdownfmt) but with a few adaptations,
6
+ including more aggressive normalization and support for wrapping of lines
7
+ semi-semantically (e.g. on sentence boundaries when appropriate).
8
+ (See [here](https://github.com/shurcooL/markdownfmt/issues/17) for some old
9
+ discussion on why line wrapping this way is convenient.)
10
+ """
11
+
12
+ import re
13
+ from contextlib import contextmanager
14
+ from textwrap import dedent
15
+ from typing import Callable, cast, Generator, List, Protocol, Tuple
16
+
17
+ from marko import block, inline
18
+ from marko.block import HTMLBlock
19
+ from marko.parser import Parser
20
+ from marko.renderer import Renderer
21
+ from marko.source import Source
22
+
23
+ from .sentence_split_regex import split_sentences_regex
24
+ from .text_wrapping import DEFAULT_LEN_FUNCTION, wrap_paragraph, wrap_paragraph_lines
25
+
26
+
27
+ DEFAULT_WRAP_WIDTH = 88
28
+ """
29
+ Default wrap width for Markdown content. This is a compromise between traditional
30
+ but sometimes impractically narrow 80-char console width and being too wide to
31
+ read comfortably for text, markup, and code. 88 is the same as Black.
32
+ """
33
+
34
+
35
+ DEFAULT_MIN_LINE_LEN = 20
36
+ """Default minimum line length for sentence breaking."""
37
+
38
+
39
+ class LineWrapper(Protocol):
40
+ """Takes a text string and any indents to use, and returns the wrapped text."""
41
+
42
+ def __call__(
43
+ self, text: str, initial_indent: str, subsequent_indent: str
44
+ ) -> str: ...
45
+
46
+
47
+ class SentenceSplitter(Protocol):
48
+ """Takes a text string and returns a list of sentences."""
49
+
50
+ def __call__(self, text: str) -> List[str]: ...
51
+
52
+
53
+ def _normalize_html_comments(text: str, break_str: str = "\n\n") -> str:
54
+ """
55
+ Put HTML comments as standalone paragraphs.
56
+ """
57
+
58
+ # Small hack to avoid changing frontmatter format, for the rare corner
59
+ # case where Markdown contains HTML-style frontmatter.
60
+ def not_frontmatter(text: str) -> bool:
61
+ return "<!---" not in text
62
+
63
+ # TODO: Probably want do this for <div>s too.
64
+ return _ensure_surrounding_breaks(
65
+ text, [("<!--", "-->")], break_str=break_str, filter=not_frontmatter
66
+ )
67
+
68
+
69
+ def _ensure_surrounding_breaks(
70
+ html: str,
71
+ tag_pairs: List[Tuple[str, str]],
72
+ filter: Callable[[str], bool] = lambda _: True,
73
+ break_str: str = "\n\n",
74
+ ) -> str:
75
+ for start_tag, end_tag in tag_pairs:
76
+ pattern = re.compile(
77
+ rf"(\s*{re.escape(start_tag)}.*?{re.escape(end_tag)}\s*)", re.DOTALL
78
+ )
79
+
80
+ def replacer(match: re.Match[str]) -> str:
81
+ if not filter(match.group(0)):
82
+ return match.group(0)
83
+
84
+ content = match.group(1).strip()
85
+ before = after = break_str
86
+
87
+ if match.start() == 0:
88
+ before = ""
89
+ if match.end() == len(html):
90
+ after = ""
91
+
92
+ return f"{before}{content}{after}"
93
+
94
+ html = re.sub(pattern, replacer, html)
95
+
96
+ return html
97
+
98
+
99
+ # XXX Turn off Marko's parsing of block HTML.
100
+ # Block parsing with comments or block elements has some counterintuitive issues:
101
+ # https://github.com/frostming/marko/issues/202
102
+ # Another solution might be to always put a newline after a closing block tag during
103
+ # normalization, to avoid this confusion?
104
+ # For now, just ignoring block tags.
105
+ class CustomHTMLBlock(HTMLBlock):
106
+ @classmethod
107
+ def match(cls, source: Source) -> int | bool:
108
+ return False
109
+
110
+
111
+ class CustomParser(Parser):
112
+ def __init__(self) -> None:
113
+ super().__init__()
114
+ self.block_elements["HTMLBlock"] = CustomHTMLBlock
115
+
116
+
117
+ class _MarkdownNormalizer(Renderer):
118
+ """
119
+ Render Markdown in normalized form. This is the internal implementation.
120
+ You likely want to use `normalize_markdown()` instead.
121
+ Based on: https://github.com/frostming/marko/blob/master/marko/md_renderer.py
122
+ """
123
+
124
+ def __init__(self, line_wrapper: LineWrapper) -> None:
125
+ super().__init__()
126
+ self._prefix: str = (
127
+ "" # The prefix on the first line, with a bullet, such as ` - `.
128
+ )
129
+ self._second_prefix: str = "" # The prefix on subsequent lines, such as ` `.
130
+ self._suppress_item_break: bool = True
131
+ self._line_wrapper = line_wrapper
132
+
133
+ def __enter__(self) -> "_MarkdownNormalizer":
134
+ self._prefix = ""
135
+ self._second_prefix = ""
136
+ return super().__enter__()
137
+
138
+ @contextmanager
139
+ def container(
140
+ self, prefix: str, second_prefix: str = ""
141
+ ) -> Generator[None, None, None]:
142
+ old_prefix, old_second_prefix = self._prefix, self._second_prefix
143
+ self._prefix += prefix
144
+ self._second_prefix += second_prefix
145
+ yield
146
+ self._prefix, self._second_prefix = old_prefix, old_second_prefix
147
+
148
+ def render_paragraph(self, element: block.Paragraph) -> str:
149
+ # Suppress item breaks on list items following a top-level paragraph.
150
+ if not self._prefix:
151
+ self._suppress_item_break = True
152
+ children = self.render_children(element)
153
+ wrapped_text = self._line_wrapper(
154
+ children,
155
+ self._prefix,
156
+ self._second_prefix,
157
+ )
158
+ self._prefix = self._second_prefix
159
+ return wrapped_text + "\n"
160
+
161
+ def render_list(self, element: block.List) -> str:
162
+ result: List[str] = []
163
+ if element.ordered:
164
+ for num, child in enumerate(element.children, element.start):
165
+ with self.container(f"{num}. ", " " * (len(str(num)) + 2)):
166
+ result.append(self.render(child))
167
+ else:
168
+ for child in element.children:
169
+ with self.container(f"{element.bullet} ", " "):
170
+ result.append(self.render(child))
171
+
172
+ self._prefix = self._second_prefix
173
+ return "".join(result)
174
+
175
+ def render_list_item(self, element: block.ListItem) -> str:
176
+ result = ""
177
+ # We want all list items to have two newlines between them.
178
+ if self._suppress_item_break:
179
+ self._suppress_item_break = False
180
+ else:
181
+ # Add the newline between paragraphs. Normally this would be an empty line but
182
+ # within a quote block it would be the secondary prefix, like `> `.
183
+ result += self._second_prefix.strip() + "\n"
184
+ result += self.render_children(element)
185
+ return result
186
+
187
+ def render_quote(self, element: block.Quote) -> str:
188
+ with self.container("> ", "> "):
189
+ result = self.render_children(element).rstrip("\n")
190
+ self._prefix = self._second_prefix
191
+ return f"{result}\n"
192
+
193
+ def _render_code(self, element: block.CodeBlock | block.FencedCode) -> str:
194
+ # Preserve code content without reformatting.
195
+ code_child = cast(inline.RawText, element.children[0])
196
+ code_content = code_child.children.rstrip("\n")
197
+ lang = element.lang if isinstance(element, block.FencedCode) else ""
198
+ extra = element.extra if isinstance(element, block.FencedCode) else ""
199
+ extra_text = f" {extra}" if extra else ""
200
+ lang_text = f"{lang}{extra_text}" if lang else ""
201
+ lines = [f"{self._prefix}```{lang_text}"]
202
+ lines.extend(
203
+ f"{self._second_prefix}{line}" for line in code_content.splitlines()
204
+ )
205
+ lines.append(f"{self._second_prefix}```")
206
+ self._prefix = self._second_prefix
207
+ return "\n".join(lines) + "\n"
208
+
209
+ def render_fenced_code(self, element: block.FencedCode) -> str:
210
+ return self._render_code(element)
211
+
212
+ def render_code_block(self, element: block.CodeBlock) -> str:
213
+ # Convert indented code blocks to fenced code blocks.
214
+ return self._render_code(element)
215
+
216
+ def render_html_block(self, element: block.HTMLBlock) -> str:
217
+ result = f"{self._prefix}{element.body}"
218
+ self._prefix = self._second_prefix
219
+ return result
220
+
221
+ def render_thematic_break(self, element: block.ThematicBreak) -> str:
222
+ result = f"{self._prefix}* * *\n"
223
+ self._prefix = self._second_prefix
224
+ return result
225
+
226
+ def render_heading(self, element: block.Heading) -> str:
227
+ result = (
228
+ f"{self._prefix}{'#' * element.level} {self.render_children(element)}\n"
229
+ )
230
+ self._prefix = self._second_prefix
231
+ return result
232
+
233
+ def render_setext_heading(self, element: block.SetextHeading) -> str:
234
+ return self.render_heading(cast("block.Heading", element))
235
+
236
+ def render_blank_line(self, element: block.BlankLine) -> str:
237
+ if self._prefix.strip():
238
+ result = f"{self._prefix}\n"
239
+ else:
240
+ result = "\n"
241
+ self._suppress_item_break = True
242
+ self._prefix = self._second_prefix
243
+ return result
244
+
245
+ def render_link_ref_def(self, element: block.LinkRefDef) -> str:
246
+ link_text = element.dest
247
+ if element.title:
248
+ link_text += f" {element.title}"
249
+ return f"[{element.label}]: {link_text}\n"
250
+
251
+ def render_emphasis(self, element: inline.Emphasis) -> str:
252
+ return f"*{self.render_children(element)}*"
253
+
254
+ def render_strong_emphasis(self, element: inline.StrongEmphasis) -> str:
255
+ return f"**{self.render_children(element)}**"
256
+
257
+ def render_inline_html(self, element: inline.InlineHTML) -> str:
258
+ return cast(str, element.children)
259
+
260
+ def render_link(self, element: inline.Link) -> str:
261
+ link_text = self.render_children(element)
262
+ link_title = (
263
+ '"{}"'.format(element.title.replace('"', '\\"')) if element.title else None
264
+ )
265
+ assert self.root_node
266
+ label = next(
267
+ (
268
+ k
269
+ for k, v in self.root_node.link_ref_defs.items()
270
+ if v == (element.dest, link_title)
271
+ ),
272
+ None,
273
+ )
274
+ if label is not None:
275
+ if label == link_text:
276
+ return f"[{label}]"
277
+ return f"[{link_text}][{label}]"
278
+ title = f" {link_title}" if link_title is not None else ""
279
+ return f"[{link_text}]({element.dest}{title})"
280
+
281
+ def render_auto_link(self, element: inline.AutoLink) -> str:
282
+ return f"<{element.dest}>"
283
+
284
+ def render_image(self, element: inline.Image) -> str:
285
+ template = "![{}]({}{})"
286
+ title = (
287
+ ' "{}"'.format(element.title.replace('"', '\\"')) if element.title else ""
288
+ )
289
+ return template.format(self.render_children(element), element.dest, title)
290
+
291
+ def render_literal(self, element: inline.Literal) -> str:
292
+ return f"\\{element.children}"
293
+
294
+ def render_raw_text(self, element: inline.RawText) -> str:
295
+ from marko.ext.pangu import PANGU_RE
296
+
297
+ return re.sub(PANGU_RE, " ", element.children)
298
+
299
+ def render_line_break(self, element: inline.LineBreak) -> str:
300
+ return "\n" if element.soft else "\\\n"
301
+
302
+ def render_code_span(self, element: inline.CodeSpan) -> str:
303
+ text = element.children
304
+ if text and (text[0] == "`" or text[-1] == "`"):
305
+ return f"`` {text} ``"
306
+ return f"`{element.children}`"
307
+
308
+
309
+ def split_sentences_no_min_length(text: str) -> List[str]:
310
+ return split_sentences_regex(text, min_length=0)
311
+
312
+
313
+ def wrap_lines_to_width(
314
+ text: str,
315
+ initial_indent: str,
316
+ subsequent_indent: str,
317
+ width: int = DEFAULT_WRAP_WIDTH,
318
+ ) -> str:
319
+ """
320
+ Wrap lines of text to a given width.
321
+ """
322
+ return wrap_paragraph(
323
+ text,
324
+ width=width,
325
+ initial_indent=initial_indent,
326
+ subsequent_indent=subsequent_indent,
327
+ )
328
+
329
+
330
+ def wrap_lines_using_sentences(
331
+ text: str,
332
+ initial_indent: str,
333
+ subsequent_indent: str,
334
+ split_sentences: SentenceSplitter = split_sentences_no_min_length,
335
+ width: int = DEFAULT_WRAP_WIDTH,
336
+ min_line_len: int = DEFAULT_MIN_LINE_LEN,
337
+ len_fn: Callable[[str], int] = DEFAULT_LEN_FUNCTION,
338
+ ) -> str:
339
+ """
340
+ Wrap lines of text to a given width but also keep sentences on their own lines.
341
+ If the last line ends up shorter than min_line_len, it's combined with the next sentence.
342
+ """
343
+ text = text.replace("\n", " ")
344
+ lines: List[str] = []
345
+ first_line = True
346
+ length = len_fn
347
+ initial_indent_len = len_fn(initial_indent)
348
+ subsequent_indent_len = len_fn(subsequent_indent)
349
+
350
+ sentences = split_sentences(text)
351
+
352
+ for i, sentence in enumerate(sentences):
353
+ current_column = initial_indent_len if first_line else subsequent_indent_len
354
+ if len(lines) > 0 and length(lines[-1]) < min_line_len:
355
+ current_column += length(lines[-1])
356
+
357
+ wrapped = wrap_paragraph_lines(
358
+ sentence,
359
+ width=width,
360
+ initial_column=current_column,
361
+ subsequent_offset=subsequent_indent_len,
362
+ )
363
+ # If last line is shorter than min_line_len, combine with next line.
364
+ # Also handles if the first word doesn't fit.
365
+ if (
366
+ len(lines) > 0
367
+ and length(lines[-1]) < min_line_len
368
+ and length(lines[-1]) + 1 + length(wrapped[0]) <= width
369
+ ):
370
+ lines[-1] += " " + wrapped[0]
371
+ wrapped.pop(0)
372
+
373
+ lines.extend(wrapped)
374
+
375
+ first_line = False
376
+
377
+ # Now insert the indents and assemble the paragraph.
378
+ if initial_indent and len(lines) > 0:
379
+ lines[0] = initial_indent + lines[0]
380
+ if subsequent_indent and len(lines) > 1:
381
+ lines[1:] = [subsequent_indent + line for line in lines[1:]]
382
+
383
+ return "\n".join(lines)
384
+
385
+
386
+ def normalize_markdown(
387
+ markdown_text: str, line_wrapper: LineWrapper = wrap_lines_using_sentences
388
+ ) -> str:
389
+ """
390
+ Normalize Markdown text. Wraps lines and adds line breaks within paragraphs and on
391
+ best-guess estimations of sentences, to make diffs more readable.
392
+
393
+ Also enforces that all list items have two newlines between them, so that items
394
+ are separate paragraphs when viewed as plaintext.
395
+ """
396
+ markdown_text = markdown_text.strip() + "\n"
397
+
398
+ # If we want to normalize HTML blocks or comments.
399
+ markdown_text = _normalize_html_comments(markdown_text)
400
+
401
+ # Normalize the markdown and wrap lines.
402
+ parser = CustomParser()
403
+ parsed = parser.parse(markdown_text)
404
+ result = _MarkdownNormalizer(line_wrapper).render(parsed)
405
+ return result
406
+
407
+
408
+ def fill_markdown(
409
+ markdown_text: str,
410
+ dedent_input: bool = True,
411
+ line_wrapper: LineWrapper = wrap_lines_to_width,
412
+ ) -> str:
413
+ """
414
+ Normalize and wrap Markdown text filling paragraphs to the full width.
415
+ Also dedents and strips the input, so it can be used on docstrings.
416
+ """
417
+ if dedent_input:
418
+ markdown_text = dedent(markdown_text).strip()
419
+ return normalize_markdown(markdown_text, line_wrapper=line_wrapper)
@@ -0,0 +1,62 @@
1
+ from typing import Callable, List
2
+
3
+ import regex
4
+
5
+
6
+ # These heuristics are from Flowmark:
7
+ # https://github.com/jlevy/atom-flowmark/blob/master/lib/remark-smart-word-wrap.js#L17-L33
8
+
9
+ # They work pretty well when used for formatting and editing documents in English.
10
+ # Note this is smarter than Python textwrap's simple heuristic:
11
+ # https://github.com/python/cpython/blob/main/Lib/textwrap.py#L105-L110
12
+
13
+ # Heuristic: End of sentence must be two letters or more, with the last letter lowercase,
14
+ # followed by a period, exclamation point, question mark. A final or preceding parenthesis
15
+ # or quote is allowed.
16
+ #
17
+ # Does not break on colon or semicolon currently as that seems to have false positives too
18
+ # often with code or other syntax.
19
+ #
20
+ # XXX: Could also handle rare cases with both quotes and parentheses at sentence end
21
+ # but may not be worth it. Also does not detect sentences ending in numerals, which
22
+ # tends to cause too many false positives. Should be OK for most Latin languages but
23
+ # may need to rethink the 2-letter restriction for some languages.
24
+ SENTENCE_RE = regex.compile(r"(\b\p{L}+[\p{Ll}])([.?!]['\"’”)]?|['\"’”)][.?!]) *$")
25
+
26
+ # Second heuristic: Very short sentences often not so useful.
27
+ SENTENCE_MIN_LENGTH = 15
28
+
29
+
30
+ def heuristic_end_of_sentence(word: str) -> bool:
31
+ return bool(SENTENCE_RE.search(word))
32
+
33
+
34
+ def split_sentences_regex(
35
+ text: str,
36
+ heuristic: Callable[[str], bool] = heuristic_end_of_sentence,
37
+ min_length: int = SENTENCE_MIN_LENGTH,
38
+ ) -> List[str]:
39
+ """
40
+ Split text into sentences using an approximate, fast regex heuristic. (English.)
41
+ Goal is to be conservative, not perfect, avoiding excessive breaks.
42
+
43
+ :param text: The text to split into sentences.
44
+ :param heuristic: A callable that returns True if text ends at the end of a sentence.
45
+ :param min_length: The minimum length of a sentence in characters.
46
+ :return: A list of sentences.
47
+ """
48
+ words = text.split()
49
+ sentences: List[str] = []
50
+ sentence: List[str] = []
51
+ words_len = 0
52
+ for word in words:
53
+ sentence.append(word)
54
+ words_len += len(word)
55
+ sentence_len = words_len + len(sentence) - 1
56
+ if heuristic(word) and sentence_len >= min_length:
57
+ sentences.append(" ".join(sentence))
58
+ sentence = []
59
+ words_len = 0
60
+ if sentence:
61
+ sentences.append(" ".join(sentence))
62
+ return sentences
@@ -0,0 +1,145 @@
1
+ import re
2
+ from enum import Enum
3
+ from typing import Callable, List
4
+
5
+ from .text_wrapping import (
6
+ DEFAULT_LEN_FUNCTION,
7
+ html_md_word_splitter,
8
+ WordSplitter,
9
+ wrap_paragraph,
10
+ )
11
+
12
+ DEFAULT_WRAP_WIDTH = 88
13
+
14
+ DEFAULT_INDENT = " "
15
+
16
+
17
+ def split_paragraphs(text: str) -> List[str]:
18
+ return [p.strip() for p in re.split(r"\n{2,}", text)]
19
+
20
+
21
+ class Wrap(Enum):
22
+ """
23
+ A few convenient text wrapping styles.
24
+ """
25
+
26
+ NONE = "none"
27
+ """No wrapping."""
28
+
29
+ WRAP = "wrap"
30
+ """Basic wrapping but preserves whitespace within paragraphs."""
31
+
32
+ WRAP_FULL = "wrap_full"
33
+ """Wraps and also normalizes whitespace."""
34
+
35
+ WRAP_INDENT = "wrap_indent"
36
+ """Wrap and also indent."""
37
+
38
+ INDENT_ONLY = "indent_only"
39
+ """Just indent."""
40
+
41
+ HANGING_INDENT = "hanging_indent"
42
+ """Wrap with hanging indent (indented except for the first line)."""
43
+
44
+ MARKDOWN_ITEM = "markdown_item"
45
+ """2-space hanging indent for markdown list items."""
46
+
47
+ @property
48
+ def initial_indent(self) -> str:
49
+ if self in [Wrap.INDENT_ONLY, Wrap.WRAP_INDENT]:
50
+ return DEFAULT_INDENT
51
+ else:
52
+ return ""
53
+
54
+ @property
55
+ def subsequent_indent(self) -> str:
56
+ if self == Wrap.MARKDOWN_ITEM:
57
+ return " "
58
+ elif self in [Wrap.INDENT_ONLY, Wrap.WRAP_INDENT, Wrap.HANGING_INDENT]:
59
+ return DEFAULT_INDENT
60
+ else:
61
+ return ""
62
+
63
+ @property
64
+ def should_wrap(self) -> bool:
65
+ return self in [
66
+ Wrap.WRAP,
67
+ Wrap.WRAP_FULL,
68
+ Wrap.WRAP_INDENT,
69
+ Wrap.HANGING_INDENT,
70
+ Wrap.MARKDOWN_ITEM,
71
+ ]
72
+
73
+ @property
74
+ def initial_indent_first_para_only(self) -> bool:
75
+ return self in [Wrap.HANGING_INDENT, Wrap.MARKDOWN_ITEM]
76
+
77
+ @property
78
+ def replace_whitespace(self) -> bool:
79
+ return self in [Wrap.WRAP_FULL, Wrap.WRAP_INDENT, Wrap.HANGING_INDENT]
80
+
81
+
82
+ def fill_text(
83
+ text: str,
84
+ text_wrap=Wrap.WRAP,
85
+ width=DEFAULT_WRAP_WIDTH,
86
+ extra_indent: str = "",
87
+ empty_indent: str = "",
88
+ initial_column: int = 0,
89
+ word_splitter: WordSplitter = html_md_word_splitter,
90
+ len_fn: Callable[[str], int] = DEFAULT_LEN_FUNCTION,
91
+ ) -> str:
92
+ """
93
+ Most flexible way to wrap and fill any number of paragraphs of plain text, with
94
+ both text wrap options and extra indentation. Use for plain text.
95
+
96
+ By default, uses the HTML and Markdown aware word splitter. This is probably
97
+ what you want, but you can also use the `simple_word_splitter` plaintext wrapping.
98
+ """
99
+
100
+ if not text_wrap.should_wrap:
101
+ indent = (
102
+ extra_indent + DEFAULT_INDENT
103
+ if text_wrap == Wrap.INDENT_ONLY
104
+ else extra_indent
105
+ )
106
+ lines = text.splitlines()
107
+ if lines:
108
+ return "\n".join(indent + line for line in lines)
109
+ else:
110
+ return empty_indent
111
+ else:
112
+ # Common settings for all wrap modes.
113
+ empty_indent = empty_indent.strip()
114
+ initial_indent = extra_indent + text_wrap.initial_indent
115
+ subsequent_indent = extra_indent + text_wrap.subsequent_indent
116
+
117
+ # These vary by wrap mode.
118
+ width = width - len_fn(subsequent_indent)
119
+ replace_whitespace = text_wrap.replace_whitespace
120
+
121
+ paragraphs = split_paragraphs(text)
122
+ wrapped_paragraphs = []
123
+
124
+ # Wrap each paragraph.
125
+ for i, paragraph in enumerate(paragraphs):
126
+ # Special case for hanging indent modes.
127
+ # Hang the first line of the first paragraph. All other paragraphs are indented.
128
+ if text_wrap.initial_indent_first_para_only and i > 0:
129
+ initial_indent = subsequent_indent
130
+
131
+ wrapped_paragraphs.append(
132
+ wrap_paragraph(
133
+ paragraph,
134
+ width=width,
135
+ initial_indent=initial_indent,
136
+ subsequent_indent=subsequent_indent,
137
+ initial_column=initial_column,
138
+ replace_whitespace=replace_whitespace,
139
+ word_splitter=word_splitter,
140
+ len_fn=len_fn,
141
+ )
142
+ )
143
+
144
+ para_sep = f"\n{empty_indent}\n"
145
+ return para_sep.join(wrapped_paragraphs)
@@ -0,0 +1,155 @@
1
+ import re
2
+ from typing import Callable, List, Protocol, Tuple
3
+
4
+
5
+ DEFAULT_LEN_FUNCTION = len
6
+ """
7
+ Default length function to use for wrapping.
8
+ By default this is just character length, but this can be overridden, for example
9
+ to use a smarter function that does not count ANSI escape codes.
10
+ """
11
+
12
+
13
+ class WordSplitter(Protocol):
14
+ def __call__(self, text: str) -> List[str]: ...
15
+
16
+
17
+ def simple_word_splitter(text: str) -> List[str]:
18
+ """
19
+ Split words on whitespace. This is like Python's normal `textwrap`.
20
+ """
21
+ return text.split()
22
+
23
+
24
+ class _HtmlMdWordSplitter:
25
+ def __init__(self):
26
+ # Sequences of whitespace-delimited words that should be coalesced and treated
27
+ # like a single word.
28
+ self.patterns = [
29
+ # HTML tags:
30
+ (r"<[^>]+", r"[^<>]+>[^<>]*"),
31
+ (r"<[^>]+", r"[^<>]+", r"[^<>]+>[^<>]*"),
32
+ # Markdown links:
33
+ (r"\[", r"[^\[\]]+\][^\[\]]*"),
34
+ (r"\[", r"[^\[\]]+", r"[^\[\]]+\][^\[\]]*"),
35
+ ]
36
+ self.compiled_patterns = [
37
+ tuple(re.compile(pattern) for pattern in pattern_group)
38
+ for pattern_group in self.patterns
39
+ ]
40
+
41
+ def __call__(self, text: str) -> List[str]:
42
+ words = text.split()
43
+ result = []
44
+ i = 0
45
+ while i < len(words):
46
+ coalesced = self.coalesce_words(words[i:])
47
+ if coalesced > 0:
48
+ result.append(" ".join(words[i : i + coalesced]))
49
+ i += coalesced
50
+ else:
51
+ result.append(words[i])
52
+ i += 1
53
+ return result
54
+
55
+ def coalesce_words(self, words: List[str]) -> int:
56
+ for pattern_group in self.compiled_patterns:
57
+ if self.match_pattern_group(words, pattern_group):
58
+ return len(pattern_group)
59
+ return 0
60
+
61
+ def match_pattern_group(self, words: List[str], patterns: Tuple[re.Pattern, ...]) -> bool:
62
+ if len(words) < len(patterns):
63
+ return False
64
+
65
+ return all(pattern.match(word) for pattern, word in zip(patterns, words))
66
+
67
+
68
+ html_md_word_splitter: WordSplitter = _HtmlMdWordSplitter()
69
+ """
70
+ Split words, but not within HTML tags or Markdown links.
71
+ """
72
+
73
+
74
+ def wrap_paragraph_lines(
75
+ text: str,
76
+ width: int,
77
+ initial_column: int = 0,
78
+ subsequent_offset: int = 0,
79
+ replace_whitespace: bool = True,
80
+ drop_whitespace: bool = True,
81
+ splitter: WordSplitter = html_md_word_splitter,
82
+ len_fn: Callable[[str], int] = DEFAULT_LEN_FUNCTION,
83
+ ) -> List[str]:
84
+ """
85
+ Wrap a single paragraph of text, returning a list of wrapped lines.
86
+ Rewritten to simplify and generalize Python's textwrap.py.
87
+ """
88
+ if replace_whitespace:
89
+ text = re.sub(r"\s+", " ", text)
90
+
91
+ words = splitter(text)
92
+
93
+ lines: List[str] = []
94
+ current_line: List[str] = []
95
+ current_width = initial_column
96
+
97
+ # Walk through words, breaking them into lines.
98
+ for word in words:
99
+ word_width = len_fn(word)
100
+
101
+ space_width = 1 if current_line else 0
102
+ if current_width + word_width + space_width <= width:
103
+ # Add word to current line.
104
+ current_line.append(word)
105
+ current_width += word_width + space_width
106
+ else:
107
+ # Start a new line.
108
+ if current_line:
109
+ line = " ".join(current_line)
110
+ if drop_whitespace:
111
+ line = line.strip()
112
+ lines.append(line)
113
+ current_line = [word]
114
+ current_width = subsequent_offset + word_width
115
+
116
+ if current_line:
117
+ line = " ".join(current_line)
118
+ if drop_whitespace:
119
+ line = line.strip()
120
+ lines.append(line)
121
+
122
+ return lines
123
+
124
+
125
+ def wrap_paragraph(
126
+ text: str,
127
+ width: int,
128
+ initial_indent: str = "",
129
+ subsequent_indent: str = "",
130
+ initial_column: int = 0,
131
+ replace_whitespace: bool = True,
132
+ drop_whitespace: bool = True,
133
+ word_splitter: WordSplitter = html_md_word_splitter,
134
+ len_fn: Callable[[str], int] = DEFAULT_LEN_FUNCTION,
135
+ ) -> str:
136
+ """
137
+ Wrap lines of a single paragraph of plain text, returning a new string.
138
+ By default, uses an HTML- and Markdown-aware word splitter.
139
+ """
140
+ lines = wrap_paragraph_lines(
141
+ text=text,
142
+ width=width,
143
+ replace_whitespace=replace_whitespace,
144
+ drop_whitespace=drop_whitespace,
145
+ splitter=word_splitter,
146
+ initial_column=initial_column + len_fn(initial_indent),
147
+ subsequent_offset=len_fn(subsequent_indent),
148
+ len_fn=len_fn,
149
+ )
150
+ # Now insert indents on first and subsequent lines, if needed.
151
+ if initial_indent and initial_column == 0 and len(lines) > 0:
152
+ lines[0] = initial_indent + lines[0]
153
+ if subsequent_indent and len(lines) > 1:
154
+ lines[1:] = [subsequent_indent + line for line in lines[1:]]
155
+ return "\n".join(lines)