pyDiffTools 0.1.42__tar.gz → 0.1.44__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.
Files changed (114) hide show
  1. {pydifftools-0.1.42/pyDiffTools.egg-info → pydifftools-0.1.44}/PKG-INFO +1 -1
  2. {pydifftools-0.1.42 → pydifftools-0.1.44/pyDiffTools.egg-info}/PKG-INFO +1 -1
  3. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/continuous.py +141 -45
  4. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/wrap_sentences.py +370 -5
  5. {pydifftools-0.1.42 → pydifftools-0.1.44}/pyproject.toml +1 -1
  6. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_wrap_check.py +314 -6
  7. {pydifftools-0.1.42 → pydifftools-0.1.44}/LICENSE.md +0 -0
  8. {pydifftools-0.1.42 → pydifftools-0.1.44}/MANIFEST.in +0 -0
  9. {pydifftools-0.1.42 → pydifftools-0.1.44}/README.rst +0 -0
  10. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/_quarto.yml +0 -0
  11. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  12. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  13. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  14. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  15. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  16. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  17. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  18. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  19. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  20. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  21. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  22. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  23. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  24. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  25. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  26. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  27. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  28. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  29. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  30. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  31. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  32. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  33. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/example.qmd +0 -0
  34. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/example.tex +0 -0
  35. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/index.qmd +0 -0
  36. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  37. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  38. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  39. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  40. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/independent.qmd +0 -0
  41. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/index.qmd +0 -0
  42. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  43. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  44. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  45. {pydifftools-0.1.42 → pydifftools-0.1.44}/example_notebook/project1/test_include.qmd +0 -0
  46. {pydifftools-0.1.42 → pydifftools-0.1.44}/pyDiffTools.egg-info/SOURCES.txt +0 -0
  47. {pydifftools-0.1.42 → pydifftools-0.1.44}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  48. {pydifftools-0.1.42 → pydifftools-0.1.44}/pyDiffTools.egg-info/entry_points.txt +0 -0
  49. {pydifftools-0.1.42 → pydifftools-0.1.44}/pyDiffTools.egg-info/requires.txt +0 -0
  50. {pydifftools-0.1.42 → pydifftools-0.1.44}/pyDiffTools.egg-info/top_level.txt +0 -0
  51. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/__init__.py +0 -0
  52. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/bibliography.py +0 -0
  53. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/browser_lifecycle.py +0 -0
  54. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/check_numbers.py +0 -0
  55. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/citation_dialog.py +0 -0
  56. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/command_line.py +0 -0
  57. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/command_registry.py +0 -0
  58. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/comment_functions.py +0 -0
  59. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/comment_migration.py +0 -0
  60. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/comment_tags.lua +0 -0
  61. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/comment_tags_margin.lua +0 -0
  62. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/comment_tags_no_comments.lua +0 -0
  63. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/comment_toggle.js +0 -0
  64. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/comments.css +0 -0
  65. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/copy_files.py +0 -0
  66. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/diff-doc.js +0 -0
  67. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/doc_contents.py +0 -0
  68. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/flowchart/__init__.py +0 -0
  69. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/flowchart/comparison.py +0 -0
  70. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  71. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/flowchart/graph.py +0 -0
  72. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  73. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/flowchart/watch_graph.py +0 -0
  74. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/flowchart/wgrph_view.js +0 -0
  75. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/forward_search.py +0 -0
  76. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/git_gd.py +0 -0
  77. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/git_gd_history.py +0 -0
  78. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/git_gd_image.py +0 -0
  79. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/git_gd_qt.py +0 -0
  80. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/html_comments.py +0 -0
  81. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/html_uncomments.py +0 -0
  82. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/log_example.py +0 -0
  83. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/match_spaces.py +0 -0
  84. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/notebook/__init__.py +0 -0
  85. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/notebook/display.py +0 -0
  86. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/notebook/fast_build.py +0 -0
  87. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/notebook/tex_to_qmd.py +0 -0
  88. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/onewordify.py +0 -0
  89. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/onewordify_undo.py +0 -0
  90. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/outline.py +0 -0
  91. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/rearrange_tex.py +0 -0
  92. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/searchacro.py +0 -0
  93. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/separate_comments.py +0 -0
  94. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/split_conflict.py +0 -0
  95. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/unseparate_comments.py +0 -0
  96. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/update_check.py +0 -0
  97. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/xml2xlsx.vbs +0 -0
  98. {pydifftools-0.1.42 → pydifftools-0.1.44}/pydifftools/zotero.py +0 -0
  99. {pydifftools-0.1.42 → pydifftools-0.1.44}/setup.cfg +0 -0
  100. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_bibliography.py +0 -0
  101. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_browser_lifecycle.py +0 -0
  102. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_check_single_use.py +0 -0
  103. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_command_line_help.py +0 -0
  104. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_comment_migration.py +0 -0
  105. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_continuous_shutdown.py +0 -0
  106. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_forward_search.py +0 -0
  107. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_git_gd_history.py +0 -0
  108. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_git_gd_image.py +0 -0
  109. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_git_gd_qt.py +0 -0
  110. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_rrng.py +0 -0
  111. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_tex_to_qmd.py +0 -0
  112. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_update_check.py +0 -0
  113. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_wrapping.py +0 -0
  114. {pydifftools-0.1.42 → pydifftools-0.1.44}/tests/test_zotero.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.42
3
+ Version: 0.1.44
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.42
3
+ Version: 0.1.44
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -132,7 +132,7 @@ def show_markdown_fix_dialog(report):
132
132
  from html import escape
133
133
 
134
134
  from PySide6.QtCore import Qt
135
- from PySide6.QtGui import QFontDatabase
135
+ from PySide6.QtGui import QFontDatabase, QFontMetrics
136
136
  from PySide6.QtWidgets import (
137
137
  QApplication,
138
138
  QDialog,
@@ -145,14 +145,8 @@ def show_markdown_fix_dialog(report):
145
145
 
146
146
  fixes = report["fixes"]
147
147
  warnings = report["warnings"]
148
- records = [
149
- {"record_type": "fix", **fix}
150
- for fix in fixes
151
- ] + [
152
- {"record_type": "warning", **warning}
153
- for warning in warnings
154
- ]
155
- if not records:
148
+ layout = report.get("layout", [])
149
+ if not fixes and not warnings and not layout:
156
150
  return
157
151
 
158
152
  dialog = QDialog()
@@ -160,7 +154,54 @@ def show_markdown_fix_dialog(report):
160
154
  QApplication.instance() or QApplication([])
161
155
  )
162
156
  dialog.setWindowTitle("Markdown source fixes")
163
- dialog.resize(760, 480)
157
+ fixed_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
158
+ # {{{ group fixes of one kind onto pages that fill the screen
159
+ screen_height = dialog.screen().availableGeometry().height()
160
+ dialog.resize(900, int(screen_height * 0.85))
161
+ # leave room for the heading, explanation, button and margins
162
+ page_rows = max(
163
+ 3,
164
+ int(
165
+ (screen_height * 0.85 - 120)
166
+ / QFontMetrics(fixed_font).lineSpacing()
167
+ ),
168
+ )
169
+ groups = {}
170
+ for fix in fixes:
171
+ groups.setdefault(fix["reason"], []).append(fix)
172
+ records = []
173
+ for reason, group in groups.items():
174
+ page = []
175
+ rows = 0
176
+ for fix in sorted(group, key=lambda fix: fix["line"]):
177
+ # a blank line separates each fix from the one before it, and a
178
+ # fix that only drops trailing spaces shows just its old line
179
+ fix_rows = (
180
+ len(fix["before"].splitlines() or [""])
181
+ + (
182
+ 0
183
+ if fix["after"] == fix["before"].rstrip()
184
+ else len(fix["after"].splitlines() or [""])
185
+ )
186
+ + (1 if page else 0)
187
+ )
188
+ if page and rows + fix_rows > page_rows:
189
+ records.append(
190
+ {"record_type": "fix", "reason": reason, "fixes": page}
191
+ )
192
+ page = []
193
+ rows = 0
194
+ fix_rows -= 1
195
+ page.append(fix)
196
+ rows += fix_rows
197
+ records.append(
198
+ {"record_type": "fix", "reason": reason, "fixes": page}
199
+ )
200
+ records += [{"record_type": "layout", **notice} for notice in layout]
201
+ records += [
202
+ {"record_type": "warning", **warning} for warning in warnings
203
+ ]
204
+ # }}}
164
205
  layout = QVBoxLayout(dialog)
165
206
  heading = QLabel()
166
207
  heading.setWordWrap(True)
@@ -175,14 +216,10 @@ def show_markdown_fix_dialog(report):
175
216
  Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight
176
217
  )
177
218
  line_numbers.setStyleSheet("color: #666; padding-right: 8px;")
178
- line_numbers.setFont(
179
- QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
180
- )
219
+ line_numbers.setFont(fixed_font)
181
220
  preview = QTextEdit()
182
221
  preview.setReadOnly(True)
183
- preview.setFont(
184
- QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
185
- )
222
+ preview.setFont(fixed_font)
186
223
  preview_row.addWidget(line_numbers)
187
224
  preview_row.addWidget(preview, 1)
188
225
  layout.addLayout(preview_row, 1)
@@ -194,37 +231,94 @@ def show_markdown_fix_dialog(report):
194
231
 
195
232
  def show_record():
196
233
  record = records[index]
197
- line = record["line"]
234
+ page = (
235
+ f" (page {index + 1} of {len(records)})"
236
+ if len(records) > 1
237
+ else ""
238
+ )
198
239
  if record["record_type"] == "fix":
199
- heading.setText("I fixed this source line automatically.")
240
+ count = len(record["fixes"])
241
+ heading.setText(
242
+ "I fixed this source line automatically." + page
243
+ if count == 1
244
+ else f"I fixed these {count} source lines automatically."
245
+ + page
246
+ )
200
247
  details.setText(record["reason"])
201
- before_lines = record["before"].splitlines() or [""]
202
- after_lines = record["after"].splitlines() or [""]
203
- source_html = ""
204
- numbers = []
205
- if "\n" in record["after"]:
206
- after_html = "<br>↳ ".join(
207
- escape(value)
208
- for value in record["after"].split("\n")
209
- )
210
- source_html = (
211
- "<pre style='font-family:monospace; margin:0'>"
212
- + "Before:<br>"
213
- + "<br>".join(escape(value) for value in before_lines)
214
- + "<br><br>After:<br>"
215
- + after_html
216
- + "</pre>"
217
- )
218
- numbers = [""] + list(
219
- range(line, line + len(before_lines))
220
- )
221
- numbers += ["", ""]
222
- numbers += list(range(line, line + len(after_lines)))
223
- line_numbers.setText("<br>".join(str(value) for value in numbers))
224
- preview.setHtml(source_html)
248
+ line_numbers.setVisible(False)
249
+ # {{{ show each fix as numbered before (−) and after (+) lines
250
+ entries = []
251
+ for fix in record["fixes"]:
252
+ rows = []
253
+ for offset, value in enumerate(
254
+ fix["before"].splitlines() or [""]
255
+ ):
256
+ stripped = value.rstrip(" ")
257
+ rows.append(
258
+ f"<span style='color:#666'>{fix['line'] + offset:>6}"
259
+ "</span> − "
260
+ + escape(stripped)
261
+ # make stray trailing spaces visible
262
+ + "<span style='background:#fbb'>·</span>"
263
+ * (len(value) - len(stripped))
264
+ )
265
+ for offset, value in enumerate(
266
+ []
267
+ if fix["after"] == fix["before"].rstrip()
268
+ else fix["after"].split("\n")
269
+ ):
270
+ rows.append(
271
+ f"<span style='color:#666'>{fix['line'] + offset:>6}"
272
+ "</span> "
273
+ + ("+ " if offset == 0 else "↳ ")
274
+ + escape(value)
275
+ )
276
+ entries.append("<br>".join(rows))
277
+ preview.setHtml(
278
+ "<pre style='font-family:monospace; margin:0'>"
279
+ + "<br><br>".join(entries)
280
+ + "</pre>"
281
+ )
282
+ # }}}
283
+ button.setText("Next" if index + 1 < len(records) else "Done")
284
+ elif record["record_type"] == "layout":
285
+ heading.setText(
286
+ "These line breaks differ from git HEAD in "
287
+ f"{record['moves']} places." + page
288
+ )
289
+ details.setText(
290
+ "Keeping these lines as they are in git HEAD would need "
291
+ f"{record['moves']} line breaks moved, so I left them as you "
292
+ "wrote them. If you did not mean to rearrange these lines, "
293
+ "lay them out as suggested."
294
+ )
295
+ line_numbers.setVisible(False)
296
+ # {{{ show HEAD, the current source and the suggested layout
297
+ sections = []
298
+ for title, text, numbered in (
299
+ ("git HEAD:", record["head"], False),
300
+ ("Now:", record["current"], True),
301
+ ("Suggested:", record["suggested"], True),
302
+ ):
303
+ rows = [escape(title)]
304
+ for offset, value in enumerate(text.split("\n")):
305
+ number = record["line"] + offset if numbered else ""
306
+ rows.append(
307
+ f"<span style='color:#666'>{number:>6}</span> "
308
+ + escape(value)
309
+ )
310
+ sections.append("<br>".join(rows))
311
+ preview.setHtml(
312
+ "<pre style='font-family:monospace; margin:0'>"
313
+ + "<br><br>".join(sections)
314
+ + "</pre>"
315
+ )
316
+ # }}}
225
317
  button.setText("Next" if index + 1 < len(records) else "Done")
226
318
  else:
227
- heading.setText("This source span is not closed.")
319
+ line = record["line"]
320
+ heading.setText("This source span is not closed." + page)
321
+ line_numbers.setVisible(True)
228
322
  if record["kind"] == "math":
229
323
  delimiter = record["delimiter"]
230
324
  details.setText(
@@ -311,9 +405,11 @@ def run_pandoc(
311
405
 
312
406
  automatic_fixes_were_made = False
313
407
  while True:
314
- report = autofix_markdown_file(filename, wrapnumber=wrapnumber)
408
+ report = autofix_markdown_file(
409
+ filename, wrapnumber=wrapnumber, git_head=True
410
+ )
315
411
  automatic_fixes_were_made |= bool(report["fixes"])
316
- if report["fixes"] or report["warnings"]:
412
+ if report["fixes"] or report["warnings"] or report["layout"]:
317
413
  user_says_fixed = show_markdown_fix_dialog(report)
318
414
  if report["warnings"]:
319
415
  if not user_says_fixed:
@@ -1,7 +1,10 @@
1
+ import os
1
2
  import re
3
+ import subprocess
2
4
  import sys
3
5
  import itertools
4
6
  from dataclasses import dataclass, field
7
+ from difflib import SequenceMatcher
5
8
 
6
9
  from .command_registry import register_command
7
10
 
@@ -1190,10 +1193,15 @@ def apply_markdown_issue_fix(content, line_number, message):
1190
1193
  else:
1191
1194
  span = found.span("gap")
1192
1195
  joined = ""
1193
- before = content[found.start() : found.end()]
1194
1196
  updated = content[: span[0]] + joined + content[span[1] :]
1197
+ # report the whole source lines around the join
1198
+ line_start = content.rfind("\n", 0, found.start()) + 1
1199
+ line_end = content.find("\n", max(found.end(), span[1]))
1200
+ if line_end == -1:
1201
+ line_end = len(content)
1202
+ before = content[line_start:line_end]
1195
1203
  after = updated[
1196
- found.start() : span[0] + len(joined) + found.end() - span[1]
1204
+ line_start : line_end - (span[1] - span[0]) + len(joined)
1197
1205
  ]
1198
1206
  return (
1199
1207
  updated,
@@ -1284,11 +1292,364 @@ def apply_markdown_issue_fix(content, line_number, message):
1284
1292
  return "".join(lines), old_line, after, reason
1285
1293
 
1286
1294
 
1287
- def autofix_markdown_file(filename, wrapnumber=55, punctuation_slop=20):
1288
- """Apply safe source line breaks and report unfinished source spans."""
1295
+ def autofix_markdown_file(
1296
+ filename, wrapnumber=55, punctuation_slop=20, git_head=False
1297
+ ):
1298
+ """Apply safe source line breaks and report unfinished source spans.
1299
+
1300
+ With `git_head`, line breaks in the hunks that differ from the file's
1301
+ git HEAD version are first placed to keep that diff small (see
1302
+ "layout" in the returned report for hunks that would need more than
1303
+ one line break moved).
1304
+ """
1289
1305
  with open(filename, encoding="utf-8", newline="") as fp:
1290
1306
  content = fp.read()
1291
1307
  fixes = []
1308
+ layout = []
1309
+ baseline = None
1310
+ if git_head:
1311
+ directory, basename = os.path.split(os.path.abspath(filename))
1312
+ try:
1313
+ with subprocess.Popen(
1314
+ ["git", "-C", directory, "show", "HEAD:./" + basename],
1315
+ stdout=subprocess.PIPE,
1316
+ stderr=subprocess.DEVNULL,
1317
+ ) as shown:
1318
+ output = shown.communicate()[0]
1319
+ if shown.returncode == 0:
1320
+ baseline = output.decode("utf-8")
1321
+ except FileNotFoundError:
1322
+ # git is not installed
1323
+ pass
1324
+ if baseline is not None:
1325
+ # {{{ place line breaks in changed hunks to keep the HEAD diff small
1326
+
1327
+ def mask_comment(match):
1328
+ return "".join("\n" if char == "\n" else " " for char in match[0])
1329
+
1330
+ prefix_pattern = re.compile(
1331
+ r"^(?P<quote>(?:[ \t]*>[ \t]?)*)(?P<indent>[ \t]*)"
1332
+ r"(?:(?P<marker>[-+*]|[0-9]+[.)])(?P<space>[ \t]+))?"
1333
+ )
1334
+
1335
+ def line_tokens(text):
1336
+ """Words of each line, after its list or quote prefix."""
1337
+ masked = re.sub(
1338
+ r"<!--.*?(?:-->|\Z)", mask_comment, text, flags=re.DOTALL
1339
+ )
1340
+ result = []
1341
+ start = 0
1342
+ for number, line in enumerate(masked.splitlines(keepends=True)):
1343
+ prefix = prefix_pattern.match(line)
1344
+ result.extend(
1345
+ (number, start + found.start(), start + found.end())
1346
+ for found in WORD.finditer(line, prefix.end())
1347
+ )
1348
+ start += len(line)
1349
+ return masked, result
1350
+
1351
+ head_lines = baseline.splitlines(keepends=True)
1352
+ lines = content.splitlines(keepends=True)
1353
+ line_starts = [0]
1354
+ for line in lines:
1355
+ line_starts.append(line_starts[-1] + len(line))
1356
+ masked, tokens = line_tokens(content)
1357
+ prose = [
1358
+ allowed and line.strip() != ""
1359
+ for allowed, line in classify_lines(masked, strict=False)
1360
+ ]
1361
+ prefixes = [prefix_pattern.match(line) for line in lines]
1362
+ continuations = [
1363
+ found["quote"]
1364
+ + found["indent"]
1365
+ + (
1366
+ " " * (len(found["marker"]) + len(found["space"]))
1367
+ if found["marker"]
1368
+ else ""
1369
+ )
1370
+ for found in prefixes
1371
+ ]
1372
+ results = []
1373
+ for operation, i1, i2, j1, j2 in SequenceMatcher(
1374
+ None, head_lines, lines, autojunk=False
1375
+ ).get_opcodes():
1376
+ if operation == "equal" or j1 == j2:
1377
+ continue
1378
+ hunk_start, hunk_end = line_starts[j1], line_starts[j2]
1379
+ hunk_tokens = [token for token in tokens if j1 <= token[0] < j2]
1380
+ head_text = "".join(head_lines[i1:i2])
1381
+ head_masked, head_tokens = line_tokens(head_text)
1382
+ matched = set()
1383
+ for block in SequenceMatcher(
1384
+ None,
1385
+ [head_masked[a:b] for _, a, b in head_tokens],
1386
+ [masked[a:b] for _, a, b in hunk_tokens],
1387
+ autojunk=False,
1388
+ ).get_matching_blocks():
1389
+ matched.update(range(block.b, block.b + block.size))
1390
+ # {{{ find the gaps between prose words where a break may move
1391
+ gaps = []
1392
+ for k in range(len(hunk_tokens) - 1):
1393
+ left_line, _, left_end = hunk_tokens[k]
1394
+ right_line, right_start, _ = hunk_tokens[k + 1]
1395
+ raw = content[left_end:right_start]
1396
+ if not (prose[left_line] and prose[right_line]):
1397
+ continue
1398
+ if left_line == right_line:
1399
+ if raw.strip(" \t"):
1400
+ continue
1401
+ elif (
1402
+ right_line != left_line + 1
1403
+ or prefixes[right_line]["marker"]
1404
+ or prefixes[right_line].group(0)
1405
+ != continuations[left_line]
1406
+ or raw.strip(" \t\r\n") != prefixes[right_line][
1407
+ "quote"
1408
+ ].strip(" \t")
1409
+ ):
1410
+ continue
1411
+ gaps.append(
1412
+ {
1413
+ "start": left_end,
1414
+ "stop": right_start,
1415
+ "newline": "\n" in raw,
1416
+ "line": left_line,
1417
+ # breaks between two newly written words are the
1418
+ # writer's choice; only lint rules move those
1419
+ "free": k not in matched and k + 1 not in matched,
1420
+ "sentence": bool(
1421
+ re.search(
1422
+ "(" + SENTENCE_END + r")$",
1423
+ content[max(0, left_end - 4) : left_end],
1424
+ )
1425
+ ),
1426
+ }
1427
+ )
1428
+ # }}}
1429
+ # a hunk this large is a wholesale reflow, not a stray line
1430
+ # break, so leave it to the lint rules alone
1431
+ if not gaps or len(gaps) > 400:
1432
+ continue
1433
+ ending = "\r\n" if "\r\n" in content[hunk_start:hunk_end] else "\n"
1434
+ rendered = {}
1435
+ scores = {}
1436
+
1437
+ def render(flags):
1438
+ """The hunk with each gap a space or a line break, and the
1439
+ (row, column) where each gap starts."""
1440
+ key = tuple(flags)
1441
+ if key not in rendered:
1442
+ output = []
1443
+ columns = []
1444
+ where = [0, 0] # row and column of the output's end
1445
+
1446
+ def append(piece):
1447
+ output.append(piece)
1448
+ if "\n" in piece:
1449
+ where[0] += piece.count("\n")
1450
+ where[1] = len(piece) - piece.rfind("\n") - 1
1451
+ else:
1452
+ where[1] += len(piece)
1453
+
1454
+ position = hunk_start
1455
+ for gap, flag in zip(gaps, flags):
1456
+ append(content[position : gap["start"]])
1457
+ columns.append(tuple(where))
1458
+ if flag == gap["newline"]:
1459
+ append(content[gap["start"] : gap["stop"]])
1460
+ elif flag:
1461
+ append(ending + continuations[gap["line"]])
1462
+ else:
1463
+ append(" ")
1464
+ position = gap["stop"]
1465
+ append(content[position:hunk_end])
1466
+ rendered[key] = ("".join(output), columns)
1467
+ return rendered[key]
1468
+
1469
+ def kept(text):
1470
+ """How many HEAD lines the hunk keeps unchanged.
1471
+
1472
+ Fewer lines alone does not count as closer to HEAD, so
1473
+ rewritten text keeps the writer's line breaks.
1474
+ """
1475
+ if text not in scores:
1476
+ scores[text] = sum(
1477
+ block.size
1478
+ for block in SequenceMatcher(
1479
+ None,
1480
+ [line.rstrip() for line in head_text.splitlines()],
1481
+ [line.rstrip() for line in text.splitlines()],
1482
+ autojunk=False,
1483
+ ).get_matching_blocks()
1484
+ )
1485
+ return scores[text]
1486
+
1487
+ def too_long(flags):
1488
+ """Rendered lines past the width that a gap could break."""
1489
+ text, columns = render(flags)
1490
+ rendered = text.splitlines()
1491
+ return [
1492
+ number
1493
+ for number in sorted(
1494
+ {row for (row, _), flag in zip(columns, flags)
1495
+ if not flag}
1496
+ )
1497
+ if len(rendered[number].rstrip()) > wrapnumber
1498
+ ]
1499
+
1500
+ original = [gap["newline"] for gap in gaps]
1501
+ # {{{ lint first: a break after each sentence, and a break in
1502
+ # each overlong line wherever it keeps the most HEAD lines
1503
+ base = [
1504
+ flag or gap["sentence"] for flag, gap in zip(original, gaps)
1505
+ ]
1506
+ for _ in range(len(gaps)):
1507
+ overlong = too_long(base)
1508
+ if not overlong:
1509
+ break
1510
+ text, columns = render(base)
1511
+ row = overlong[0]
1512
+ line_text = text.splitlines()[row]
1513
+ prefix = prefix_pattern.match(line_text).group(0)
1514
+ words = WORD.findall(line_text[len(prefix) :])
1515
+ upto = _next_break(
1516
+ words,
1517
+ wrapnumber - len(prefix),
1518
+ punctuation_slop,
1519
+ _math_boundaries(words),
1520
+ 0,
1521
+ )
1522
+ wr_column = len(prefix) + len(" ".join(words[: upto + 1]))
1523
+ candidates = [
1524
+ number
1525
+ for number, ((gap_row, column), flag) in enumerate(
1526
+ zip(columns, base)
1527
+ )
1528
+ if gap_row == row and not flag
1529
+ ]
1530
+ fitting = [
1531
+ number
1532
+ for number in candidates
1533
+ if columns[number][1] <= wrapnumber
1534
+ ] or candidates[:1]
1535
+
1536
+ def with_break(number):
1537
+ return [
1538
+ flag or other == number
1539
+ for other, flag in enumerate(base)
1540
+ ]
1541
+
1542
+ base = with_break(
1543
+ min(
1544
+ fitting,
1545
+ key=lambda number: (
1546
+ -kept(render(with_break(number))[0]),
1547
+ abs(columns[number][1] - wr_column),
1548
+ ),
1549
+ )
1550
+ )
1551
+ # }}}
1552
+ # {{{ then join or move breaks next to HEAD's words while that
1553
+ # restores HEAD lines; only the lint rules above may add a break
1554
+ best = list(base)
1555
+ best_kept = kept(render(best)[0])
1556
+ moves = 0
1557
+ for _ in range(len(gaps)):
1558
+ trials = []
1559
+ for number, gap in enumerate(gaps):
1560
+ if (
1561
+ not best[number]
1562
+ or gap["free"]
1563
+ or gap["sentence"]
1564
+ ):
1565
+ continue
1566
+ joined = list(best)
1567
+ joined[number] = False
1568
+ trials.append(joined)
1569
+ # or slide the break to another gap of the joined line
1570
+ columns = render(joined)[1]
1571
+ for other, flag in enumerate(joined):
1572
+ if (
1573
+ not flag
1574
+ and other != number
1575
+ and columns[other][0] == columns[number][0]
1576
+ ):
1577
+ moved = list(joined)
1578
+ moved[other] = True
1579
+ trials.append(moved)
1580
+ limit = len(too_long(best))
1581
+ scored = [
1582
+ (kept(render(trial)[0]), trial)
1583
+ for trial in trials
1584
+ if len(too_long(trial)) <= limit
1585
+ ]
1586
+ if not scored:
1587
+ break
1588
+ trial_kept, trial = max(scored, key=lambda item: item[0])
1589
+ if trial_kept <= best_kept:
1590
+ break
1591
+ best_kept, best = trial_kept, trial
1592
+ moves += 1
1593
+ # }}}
1594
+ chosen = best if moves == 1 else base
1595
+ results.append(
1596
+ {
1597
+ "j1": j1,
1598
+ "j2": j2,
1599
+ "start": hunk_start,
1600
+ "stop": hunk_end,
1601
+ "old": content[hunk_start:hunk_end],
1602
+ "new": render(chosen)[0],
1603
+ "linted": base != original,
1604
+ "moved": moves == 1,
1605
+ "head": head_text,
1606
+ "suggested": render(best)[0] if moves > 1 else None,
1607
+ "moves": moves,
1608
+ }
1609
+ )
1610
+ shift = 0
1611
+ for result in results:
1612
+ line = result["j1"] + 1 + shift
1613
+ if result["new"] != result["old"]:
1614
+ reasons = []
1615
+ if result["linted"]:
1616
+ reasons.append(
1617
+ "This edit made a line too long or ended a sentence "
1618
+ "mid-line. I broke it where it keeps the most lines "
1619
+ "unchanged from git HEAD."
1620
+ )
1621
+ if result["moved"]:
1622
+ reasons.append(
1623
+ "This edit moved a line break away from where git "
1624
+ "HEAD has it. I put it back so the unchanged lines "
1625
+ "stay unchanged."
1626
+ )
1627
+ fixes.append(
1628
+ {
1629
+ "line": line,
1630
+ "reason": " ".join(reasons),
1631
+ "before": result["old"].rstrip("\r\n"),
1632
+ "after": result["new"].rstrip("\r\n"),
1633
+ }
1634
+ )
1635
+ if result["suggested"] is not None:
1636
+ layout.append(
1637
+ {
1638
+ "line": line,
1639
+ "moves": result["moves"],
1640
+ "head": result["head"].rstrip("\r\n"),
1641
+ "current": result["new"].rstrip("\r\n"),
1642
+ "suggested": result["suggested"].rstrip("\r\n"),
1643
+ }
1644
+ )
1645
+ shift += result["new"].count("\n") - result["old"].count("\n")
1646
+ for result in reversed(results):
1647
+ content = (
1648
+ content[: result["start"]]
1649
+ + result["new"]
1650
+ + content[result["stop"] :]
1651
+ )
1652
+ # }}}
1292
1653
  # Lint once, fix every flagged line, then lint again, since fixing one
1293
1654
  # issue at a time re-lints the whole file for every single fix.
1294
1655
  for _ in range(100):
@@ -1333,7 +1694,11 @@ def autofix_markdown_file(filename, wrapnumber=55, punctuation_slop=20):
1333
1694
  if fixes:
1334
1695
  with open(filename, "w", encoding="utf-8", newline="") as fp:
1335
1696
  fp.write(content)
1336
- return {"fixes": fixes, "warnings": unclosed_markdown_spans(content)}
1697
+ return {
1698
+ "fixes": fixes,
1699
+ "warnings": unclosed_markdown_spans(content),
1700
+ "layout": layout,
1701
+ }
1337
1702
 
1338
1703
 
1339
1704
  @register_command(
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyDiffTools"
7
- version = "0.1.42"
7
+ version = "0.1.44"
8
8
  authors = [{ name = "J M Franck" }]
9
9
  description = "Diff tools"
10
10
  readme = "README.rst"