flowmark 0.1.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.
- flowmark/__init__.py +19 -0
- flowmark/markdown_filling.py +419 -0
- flowmark/sentence_split_regex.py +62 -0
- flowmark/text_filling.py +145 -0
- flowmark/text_wrapping.py +155 -0
- flowmark-0.1.0.dist-info/LICENSE +21 -0
- flowmark-0.1.0.dist-info/METADATA +53 -0
- flowmark-0.1.0.dist-info/RECORD +9 -0
- flowmark-0.1.0.dist-info/WHEEL +4 -0
flowmark/__init__.py
ADDED
|
@@ -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
|
flowmark/text_filling.py
ADDED
|
@@ -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)
|
|
@@ -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,9 @@
|
|
|
1
|
+
flowmark/__init__.py,sha256=pKlPLUQs2wJwaNcaGbXAPoQ5Rp2dtUOLSuEq5laV4UI,442
|
|
2
|
+
flowmark/markdown_filling.py,sha256=e_5N-3lhhZe35aCaMk5PIq_Ke7R1IZgP6UBviu-34Sw,14629
|
|
3
|
+
flowmark/sentence_split_regex.py,sha256=ZL-hnyonE0i3pH74nmEjYgV0B7beD0bJdq3QvCiBPPs,2400
|
|
4
|
+
flowmark/text_filling.py,sha256=QsqrrwuKnT6Ywsp_qjLgINNjHlK1b5vVmDfvR_CVtv8,4248
|
|
5
|
+
flowmark/text_wrapping.py,sha256=Y6ybMU6W0_o2zSQurhaiwLAiqkiRLgF9tHhbAmYswPM,4841
|
|
6
|
+
flowmark-0.1.0.dist-info/LICENSE,sha256=JhzkmuO3Ur7MsQaef8szzVks_bSjkLP_fYjwwUW8-xI,1068
|
|
7
|
+
flowmark-0.1.0.dist-info/METADATA,sha256=I-7LSS76jKtEpDhJIMU_rokT2XCD_zy6gxjcCqLU9QY,2220
|
|
8
|
+
flowmark-0.1.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
9
|
+
flowmark-0.1.0.dist-info/RECORD,,
|