pyDiffTools 0.1.54__tar.gz → 0.1.56__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 (130) hide show
  1. {pydifftools-0.1.54/pyDiffTools.egg-info → pydifftools-0.1.56}/PKG-INFO +4 -2
  2. {pydifftools-0.1.54 → pydifftools-0.1.56}/README.rst +3 -1
  3. {pydifftools-0.1.54 → pydifftools-0.1.56/pyDiffTools.egg-info}/PKG-INFO +4 -2
  4. {pydifftools-0.1.54 → pydifftools-0.1.56}/pyDiffTools.egg-info/SOURCES.txt +1 -0
  5. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/source_jump.js +12 -3
  6. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/line_alignment.py +60 -26
  7. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/match_spaces.py +7 -1
  8. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/notebook/fast_build.py +115 -5
  9. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/wrap_sentences.py +24 -5
  10. {pydifftools-0.1.54 → pydifftools-0.1.56}/pyproject.toml +1 -1
  11. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_forward_search.py +20 -0
  12. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_line_alignment.py +83 -8
  13. pydifftools-0.1.56/tests/test_qt_lifecycle.py +51 -0
  14. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_wrap_check.py +50 -16
  15. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_wrapping.py +8 -2
  16. {pydifftools-0.1.54 → pydifftools-0.1.56}/LICENSE.md +0 -0
  17. {pydifftools-0.1.54 → pydifftools-0.1.56}/MANIFEST.in +0 -0
  18. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/_quarto.yml +0 -0
  19. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  20. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  21. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  22. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  23. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  24. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  25. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  26. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  27. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  28. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  29. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  30. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  31. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  32. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  33. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  34. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  35. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  36. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  37. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  38. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  39. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  40. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  41. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/example.qmd +0 -0
  42. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/example.tex +0 -0
  43. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/index.qmd +0 -0
  44. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  45. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  46. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  47. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  48. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/independent.qmd +0 -0
  49. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/index.qmd +0 -0
  50. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  51. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  52. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  53. {pydifftools-0.1.54 → pydifftools-0.1.56}/example_notebook/project1/test_include.qmd +0 -0
  54. {pydifftools-0.1.54 → pydifftools-0.1.56}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  55. {pydifftools-0.1.54 → pydifftools-0.1.56}/pyDiffTools.egg-info/entry_points.txt +0 -0
  56. {pydifftools-0.1.54 → pydifftools-0.1.56}/pyDiffTools.egg-info/requires.txt +0 -0
  57. {pydifftools-0.1.54 → pydifftools-0.1.56}/pyDiffTools.egg-info/top_level.txt +0 -0
  58. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/__init__.py +0 -0
  59. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/bibliography.py +0 -0
  60. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/browser_lifecycle.py +0 -0
  61. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/check_numbers.py +0 -0
  62. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/citation_dialog.py +0 -0
  63. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/command_line.py +0 -0
  64. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/command_registry.py +0 -0
  65. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comment_filter_history.json +0 -0
  66. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comment_functions.py +0 -0
  67. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comment_migration.py +0 -0
  68. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comment_tags.lua +0 -0
  69. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comment_tags_margin.lua +0 -0
  70. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comment_tags_no_comments.lua +0 -0
  71. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comment_toggle.js +0 -0
  72. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comments.css +0 -0
  73. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/comments_author_colors.css +0 -0
  74. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/completions/git-mergein +0 -0
  75. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/continuous.py +0 -0
  76. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/copy_files.py +0 -0
  77. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/diff-doc.js +0 -0
  78. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/doc_contents.py +0 -0
  79. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/__init__.py +0 -0
  80. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/comparison.py +0 -0
  81. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  82. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/graph.py +0 -0
  83. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  84. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/watch_graph.py +0 -0
  85. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/flowchart/wgrph_view.js +0 -0
  86. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/forward_search.py +0 -0
  87. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/git_aliases.py +0 -0
  88. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/git_gd.py +0 -0
  89. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/git_gd_history.py +0 -0
  90. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/git_gd_image.py +0 -0
  91. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/git_gd_qt.py +0 -0
  92. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/html_comments.py +0 -0
  93. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/html_uncomments.py +0 -0
  94. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/log_example.py +0 -0
  95. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/notebook/__init__.py +0 -0
  96. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/notebook/display.py +0 -0
  97. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/notebook/tex_to_qmd.py +0 -0
  98. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/onewordify.py +0 -0
  99. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/onewordify_undo.py +0 -0
  100. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/outline.py +0 -0
  101. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/pdf_diff.py +0 -0
  102. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/rearrange_tex.py +0 -0
  103. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/searchacro.py +0 -0
  104. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/separate_comments.py +0 -0
  105. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/source_jump.py +0 -0
  106. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/split_conflict.py +0 -0
  107. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/unseparate_comments.py +0 -0
  108. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/update_check.py +0 -0
  109. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/wrapping_options.py +0 -0
  110. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/xml2xlsx.vbs +0 -0
  111. {pydifftools-0.1.54 → pydifftools-0.1.56}/pydifftools/zotero.py +0 -0
  112. {pydifftools-0.1.54 → pydifftools-0.1.56}/setup.cfg +0 -0
  113. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_bibliography.py +0 -0
  114. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_browser_lifecycle.py +0 -0
  115. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_check_single_use.py +0 -0
  116. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_command_line_help.py +0 -0
  117. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_comment_filters.py +0 -0
  118. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_comment_migration.py +0 -0
  119. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_continuous_shutdown.py +0 -0
  120. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_git_gd_history.py +0 -0
  121. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_git_gd_image.py +0 -0
  122. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_git_gd_qt.py +0 -0
  123. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_git_mergein.py +0 -0
  124. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_git_pdf_diff.py +0 -0
  125. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_pdf_diff.py +0 -0
  126. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_rrng.py +0 -0
  127. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_source_jump.py +0 -0
  128. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_tex_to_qmd.py +0 -0
  129. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_update_check.py +0 -0
  130. {pydifftools-0.1.54 → pydifftools-0.1.56}/tests/test_zotero.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.54
3
+ Version: 0.1.56
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -306,7 +306,9 @@ included are (listed in order of fun/utility):
306
306
  * ``cpb`` and ``wmatch`` share a matcher that finds changed hunks, aligns
307
307
  their words using Git's minimal diff, and restores reference whitespace.
308
308
  It then considers extra breaks that preserve an unchanged reference line
309
- without stranding a tiny edited fragment, and checks wrapping rules.
309
+ without stranding an edited fragment of three words or fewer, and checks
310
+ wrapping rules. When matching a baseline, edited lines may exceed the
311
+ character width by at most four words before wrapping is required.
310
312
 
311
313
  * ``--wrapnumber`` sets the maximum width (79 for ``cpb`` and ``wmatch``).
312
314
  Wmatch also uses nearby reference widths for large edits, retaining
@@ -251,7 +251,9 @@ included are (listed in order of fun/utility):
251
251
  * ``cpb`` and ``wmatch`` share a matcher that finds changed hunks, aligns
252
252
  their words using Git's minimal diff, and restores reference whitespace.
253
253
  It then considers extra breaks that preserve an unchanged reference line
254
- without stranding a tiny edited fragment, and checks wrapping rules.
254
+ without stranding an edited fragment of three words or fewer, and checks
255
+ wrapping rules. When matching a baseline, edited lines may exceed the
256
+ character width by at most four words before wrapping is required.
255
257
 
256
258
  * ``--wrapnumber`` sets the maximum width (79 for ``cpb`` and ``wmatch``).
257
259
  Wmatch also uses nearby reference widths for large edits, retaining
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.54
3
+ Version: 0.1.56
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -306,7 +306,9 @@ included are (listed in order of fun/utility):
306
306
  * ``cpb`` and ``wmatch`` share a matcher that finds changed hunks, aligns
307
307
  their words using Git's minimal diff, and restores reference whitespace.
308
308
  It then considers extra breaks that preserve an unchanged reference line
309
- without stranding a tiny edited fragment, and checks wrapping rules.
309
+ without stranding an edited fragment of three words or fewer, and checks
310
+ wrapping rules. When matching a baseline, edited lines may exceed the
311
+ character width by at most four words before wrapping is required.
310
312
 
311
313
  * ``--wrapnumber`` sets the maximum width (79 for ``cpb`` and ``wmatch``).
312
314
  Wmatch also uses nearby reference widths for large edits, retaining
@@ -118,6 +118,7 @@ tests/test_git_mergein.py
118
118
  tests/test_git_pdf_diff.py
119
119
  tests/test_line_alignment.py
120
120
  tests/test_pdf_diff.py
121
+ tests/test_qt_lifecycle.py
121
122
  tests/test_rrng.py
122
123
  tests/test_source_jump.py
123
124
  tests/test_tex_to_qmd.py
@@ -1,11 +1,11 @@
1
- // Add a small local-only source jump action to cpb and flowchart previews.
1
+ // Add a small local-only source jump action to cpb, qmdb and flowchart previews.
2
2
  (function () {
3
3
  const endpoint = window.pydifftSourceJumpEndpoint;
4
4
  if (!endpoint) {
5
5
  return;
6
6
  }
7
7
  const excludedSourceElements =
8
- '.math,.MathJax,mjx-container,.katex,math,' +
8
+ '.pydifft-obs-prefix,.math,.MathJax,mjx-container,.katex,math,' +
9
9
  'script[type^="math/tex"],' +
10
10
  'a[href*="#fig:"],a[href*="#fig-"],a[href*="#fig%3A"]';
11
11
 
@@ -27,6 +27,7 @@
27
27
  });
28
28
 
29
29
  let phrase = '';
30
+ let source = null;
30
31
 
31
32
  // Preserve HTML tag boundaries without adding spaces to words such as
32
33
  // H<sub>2</sub>O. Python treats this reserved marker as a formatting gap.
@@ -43,6 +44,9 @@
43
44
 
44
45
  function showMenu(event, text) {
45
46
  phrase = normalizeSourceText(text || '');
47
+ const sourceElement = event.target && event.target.closest
48
+ ? event.target.closest('[data-pydifft-source]') : null;
49
+ source = sourceElement ? sourceElement.dataset.pydifftSource : null;
46
50
  if (!phrase) {
47
51
  hideMenu();
48
52
  return;
@@ -102,6 +106,11 @@
102
106
  text += ' ';
103
107
  return;
104
108
  }
109
+ if (node.nodeType === Node.ELEMENT_NODE &&
110
+ (node.tagName === 'BR' || node.tagName === 'IMG')) {
111
+ text += ' ';
112
+ return;
113
+ }
105
114
  const tagBoundary = node !== block && node.nodeType === Node.ELEMENT_NODE;
106
115
  if (tagBoundary) {
107
116
  text += '\uE000';
@@ -144,7 +153,7 @@
144
153
  fetch(endpoint, {
145
154
  method: 'POST',
146
155
  mode: 'no-cors',
147
- body: phrase,
156
+ body: source ? JSON.stringify({source: source, phrase: phrase}) : phrase,
148
157
  }).catch(function () {
149
158
  // The preview may be closing while the local editor request is sent.
150
159
  });
@@ -256,6 +256,7 @@ def align_line_breaks(
256
256
  if allowed:
257
257
  for line, message in check_prose(
258
258
  span, width, punctuation_slop, offset,
259
+ overflow_words=4,
259
260
  ):
260
261
  issues.setdefault(line, message)
261
262
  offset += span.count("\n")
@@ -462,6 +463,58 @@ def align_line_breaks(
462
463
  old_rows = {}
463
464
  for number, (row, _, _) in enumerate(old_tokens):
464
465
  old_rows.setdefault(row, [number, number])[1] = number
466
+ for row, (first, last) in old_rows.items():
467
+ if (
468
+ all(number in reverse for number in range(first, last + 1))
469
+ and reverse[last] - reverse[first] == last - first
470
+ ):
471
+ spans.append((
472
+ reverse[first], reverse[last],
473
+ reference_lines[i1 + row].rstrip(),
474
+ ))
475
+ inherited = {(first, last) for first, last, _ in spans}
476
+ token_gaps = [
477
+ before[left[2]:right[1]]
478
+ for left, right in zip(tokens, tokens[1:])
479
+ ]
480
+
481
+ def creates_short_fragment(proposed, changed):
482
+ # Inspect only rows beside newly inserted breaks. Token
483
+ # ranges identify the actual inherited line, even when its
484
+ # text occurs elsewhere. Stop counting after four words.
485
+ for number in changed:
486
+ if (
487
+ "\n" not in proposed[number]
488
+ or "\n" in whitespace[number]
489
+ ):
490
+ continue
491
+ for direction, first in ((-1, number), (1, number + 1)):
492
+ last = first
493
+ words = len(before[
494
+ tokens[first][1]:tokens[first][2]
495
+ ].split())
496
+ while words <= 3 and (
497
+ (direction < 0 and first > 0)
498
+ or (direction > 0 and last < len(tokens) - 1)
499
+ ):
500
+ gap = first - 1 if direction < 0 else last
501
+ raw = proposed.get(gap, token_gaps[gap])
502
+ if "\n" in raw:
503
+ break
504
+ adjacent = first - 1 if direction < 0 else last + 1
505
+ words += len(before[
506
+ tokens[adjacent][1]:tokens[adjacent][2]
507
+ ].split()) - (not raw)
508
+ if words > 3:
509
+ break
510
+ if direction < 0:
511
+ first -= 1
512
+ else:
513
+ last += 1
514
+ if words <= 3 and (first, last) not in inherited:
515
+ return True
516
+ return False
517
+
465
518
  for number in gaps:
466
519
  old_number = matches[hunk].get(number)
467
520
  if old_number is None and number + 1 in matches[hunk]:
@@ -477,14 +530,13 @@ def align_line_breaks(
477
530
  region is not None
478
531
  and start + tokens[number + 1][1] < region[1]
479
532
  )
480
- if (
481
- number not in matches[hunk] and "\n" in raw
482
- and "\n" not in gaps[number] and not inside_region
483
- and len("".join(
484
- before[:tokens[number][2]].split("\n")[-1].split()
485
- )) < 10
533
+ if "\n" in raw and "\n" not in gaps[number] and (
534
+ not inside_region
486
535
  ):
487
- continue
536
+ proposed = dict(whitespace)
537
+ proposed[number] = raw
538
+ if creates_short_fragment(proposed, (number,)):
539
+ continue
488
540
  if inside_region or (
489
541
  current_layout[3][j1 + tokens[number][0]]
490
542
  and current_layout[3][j1 + tokens[number + 1][0]]
@@ -495,21 +547,8 @@ def align_line_breaks(
495
547
  ending + continuation[tokens[number][0]]
496
548
  if "\n" in raw else raw
497
549
  )
498
- for row, (first, last) in old_rows.items():
499
- if (
500
- all(number in reverse for number in range(first, last + 1))
501
- and reverse[last] - reverse[first] == last - first
502
- ):
503
- spans.append(
504
- (
505
- reverse[first],
506
- reverse[last],
507
- reference_lines[i1 + row].rstrip(),
508
- )
509
- )
510
550
  # }}}
511
551
  # {{{ additional breaks preserve a line without tiny new fragments
512
- inherited = {line.rstrip() for line in reference_lines}
513
552
  for first, last, old_line in spans:
514
553
  needed = range(
515
554
  max(0, first - 1), min(len(tokens) - 1, last + 1)
@@ -543,12 +582,7 @@ def align_line_breaks(
543
582
  continue
544
583
  if old_line not in {line.rstrip() for line in proposed_rows}:
545
584
  continue
546
- if any(
547
- line not in original_rows
548
- and line.rstrip() not in inherited
549
- and 0 < len("".join(line.split())) < 10
550
- for line in proposed_rows
551
- ):
585
+ if creates_short_fragment(proposed, needed):
552
586
  continue
553
587
  whitespace = proposed
554
588
  # }}}
@@ -1,6 +1,7 @@
1
1
  from difflib import SequenceMatcher
2
2
  from bisect import bisect_left
3
3
  import math
4
+ import re
4
5
  from .line_alignment import align_line_breaks
5
6
  from .wrapping_options import (
6
7
  DEFAULT_WIDTH,
@@ -117,7 +118,12 @@ def match_text(
117
118
  return (
118
119
  local
119
120
  if any(
120
- len(line) > (local if joined else 1.5 * local)
121
+ len(line) > local and (
122
+ joined or sum(
123
+ match.end() > local
124
+ for match in re.finditer(r"\S+", line)
125
+ ) > 4
126
+ )
121
127
  for line in restored.splitlines()
122
128
  )
123
129
  else fallback_width
@@ -21,6 +21,7 @@ import shutil
21
21
  import queue
22
22
  import yaml
23
23
  from pydifftools.command_registry import register_command
24
+ from pydifftools.source_jump import jump_to_source, MAX_SOURCE_JUMP_BYTES
24
25
  from pydifftools.browser_lifecycle import (
25
26
  browser_window_is_alive,
26
27
  close_browser_window,
@@ -61,6 +62,7 @@ CODE_DISPLAY_MODES = {
61
62
  }
62
63
  # {{{ Rebuild endpoint URL (changeable parameters)
63
64
  RERUN_ENDPOINT = "/__pydifft/rerun"
65
+ SOURCE_JUMP_ENDPOINT = "/__pydifft/jump"
64
66
  # }}}
65
67
  # {{{ Right-click "SOURCE" to rebuild a notebook through the qmdb server
66
68
  RERUN_SCRIPT = """
@@ -80,6 +82,7 @@ RERUN_SCRIPT = """
80
82
  return;
81
83
  }
82
84
  e.preventDefault();
85
+ e.stopImmediatePropagation();
83
86
  menu = document.createElement("div");
84
87
  menu.textContent = "Rebuild";
85
88
  menu.style.cssText = "position:fixed;z-index:2147483647;"
@@ -2210,11 +2213,32 @@ def _ensure_rerun_script(root):
2210
2213
  head.append(script)
2211
2214
 
2212
2215
 
2216
+ def _normalize_observation_html(text):
2217
+ """Keep raw observation boundaries intact across paragraphs and figures."""
2218
+ # HTML parsers close unknown inline tags at </p>. A block-capable
2219
+ # container keeps the closing tag bounding all of the observed prose.
2220
+ text = re.sub(
2221
+ r"<obs\b([^>]*)>",
2222
+ r'<div class="pydifft-observation"\1>',
2223
+ text,
2224
+ flags=re.IGNORECASE,
2225
+ )
2226
+ text = re.sub(r"</obs\s*>", "</div>", text, flags=re.IGNORECASE)
2227
+ return text
2228
+
2229
+
2213
2230
  # also used by: tests/notebook/test_fast_build.py, which calls or
2214
2231
  # monkeypatches this notebook-builder entry point.
2215
2232
  def postprocess_html(html_path: Path, include_root: Path, resource_root: Path):
2216
2233
  """Replace placeholder nodes with referenced HTML bodies."""
2217
- root = lxml_html.fromstring(html_path.read_text())
2234
+ root = lxml_html.fromstring(
2235
+ _normalize_observation_html(html_path.read_text())
2236
+ )
2237
+ # Mark fragments before expansion to retain nested include source paths.
2238
+ source = Path(os.path.relpath(html_path, DISPLAY_DIR)).with_suffix(".qmd")
2239
+ for node in root.xpath("//body") or [root]:
2240
+ if not node.get("data-pydifft-source"):
2241
+ node.set("data-pydifft-source", source.as_posix())
2218
2242
  # keep processing until no include placeholders remain so nested includes
2219
2243
  # are fully expanded in the served HTML
2220
2244
  while True:
@@ -2229,7 +2253,9 @@ def postprocess_html(html_path: Path, include_root: Path, resource_root: Path):
2229
2253
  target = (include_root / target_rel).resolve()
2230
2254
  if target.exists():
2231
2255
  frag_text = target.read_text()
2232
- frag = lxml_html.fromstring(frag_text)
2256
+ frag = lxml_html.fromstring(
2257
+ _normalize_observation_html(frag_text)
2258
+ )
2233
2259
  body = frag.xpath("body")
2234
2260
  if body:
2235
2261
  elems = list(body[0])
@@ -2244,6 +2270,12 @@ def postprocess_html(html_path: Path, include_root: Path, resource_root: Path):
2244
2270
  start_c = lxml_html.HtmlComment(f"BEGIN include {target_rel}")
2245
2271
  parent.insert(idx, end_c)
2246
2272
  for elem in reversed(elems):
2273
+ if isinstance(elem.tag, str):
2274
+ elem.set(
2275
+ "data-pydifft-source",
2276
+ elem.get("data-pydifft-source")
2277
+ or Path(target_rel).with_suffix(".qmd").as_posix(),
2278
+ )
2247
2279
  parent.insert(idx, elem)
2248
2280
  parent.insert(idx, start_c)
2249
2281
  progress = True
@@ -2318,6 +2350,45 @@ def postprocess_html(html_path: Path, include_root: Path, resource_root: Path):
2318
2350
  # Includes contribute their bodies, so their head scripts are lost.
2319
2351
  # Install the handler once on the final page after expanding includes.
2320
2352
  _ensure_rerun_script(root)
2353
+ # {{{ Style observations left raw when images split Pandoc inline lists
2354
+ for obs in root.xpath('//div[@class="pydifft-observation"]'):
2355
+ # Inline display preserves observations embedded in running prose.
2356
+ obs.set("style", obs.get("style", "") + ";display:inline;color:blue;")
2357
+ if not obs.xpath('./span[@class="pydifft-obs-prefix"]'):
2358
+ metadata = " ".join(filter(None, (
2359
+ obs.get("time") or obs.get("date"), obs.get("author")
2360
+ )))
2361
+ if metadata:
2362
+ prefix = lxml_html.Element("span")
2363
+ prefix.set("class", "pydifft-obs-prefix")
2364
+ prefix.set("style", "font-size:85%;color:green;")
2365
+ prefix.text = metadata + ":"
2366
+ prefix.tail = " " + (obs.text or "")
2367
+ obs.text = None
2368
+ obs.insert(0, prefix)
2369
+ # }}}
2370
+ # {{{ Reuse CPB's context menu on every final QMDB page
2371
+ head = root.find("head")
2372
+ if head is None:
2373
+ head = lxml_html.Element("head")
2374
+ root.insert(0, head)
2375
+ for old in root.xpath(
2376
+ '//script[@id="pydifft-source-jump-config" or '
2377
+ '@id="pydifft-source-jump"]'
2378
+ ):
2379
+ old.getparent().remove(old)
2380
+ config = lxml_html.Element("script", id="pydifft-source-jump-config")
2381
+ config.text = (
2382
+ "window.pydifftSourceJumpEndpoint = "
2383
+ + json.dumps(SOURCE_JUMP_ENDPOINT) + ";"
2384
+ )
2385
+ head.append(config)
2386
+ script = lxml_html.Element("script", id="pydifft-source-jump")
2387
+ script.text = (
2388
+ Path(__file__).parents[1] / "flowchart" / "source_jump.js"
2389
+ ).read_text(encoding="utf-8")
2390
+ head.append(script)
2391
+ # }}}
2321
2392
  html_path.write_text(lxml_html.tostring(root, encoding="unicode"))
2322
2393
 
2323
2394
 
@@ -2335,8 +2406,10 @@ def substitute_code_placeholders(
2335
2406
  if code_display not in CODE_DISPLAY_MODES:
2336
2407
  raise ValueError(f"unknown code display mode: {code_display}")
2337
2408
  parser = lxml_html.HTMLParser(encoding="utf-8")
2338
- tree = lxml_html.parse(str(html_path), parser)
2339
- root = tree.getroot()
2409
+ root = lxml_html.document_fromstring(
2410
+ _normalize_observation_html(html_path.read_text()), parser=parser
2411
+ )
2412
+ tree = root.getroottree()
2340
2413
  formatter = HtmlFormatter()
2341
2414
  head = root.xpath("//head")
2342
2415
  if head and not root.xpath('//style[@id="pygments-style"]'):
@@ -2379,7 +2452,9 @@ def substitute_code_placeholders(
2379
2452
  frags = [details]
2380
2453
  # }}}
2381
2454
  if not missing_output and html:
2382
- frags += lxml_html.fragments_fromstring(html)
2455
+ frags += lxml_html.fragments_fromstring(
2456
+ _normalize_observation_html(html)
2457
+ )
2383
2458
  elif missing_output:
2384
2459
  # Only show the placeholder when the notebook output entry is
2385
2460
  # absent so executed cells that intentionally produce no output
@@ -2499,6 +2574,41 @@ def watch_and_serve(
2499
2574
  super().__init__(*args, directory=str(DISPLAY_DIR), **kwargs)
2500
2575
 
2501
2576
  def do_POST(self):
2577
+ if self.path == SOURCE_JUMP_ENDPOINT:
2578
+ # {{{ Dispatch the shared menu to the fragment's QMD source
2579
+ origin = self.headers.get("Origin")
2580
+ if origin and origin.split("://", 1)[-1] != self.headers.get(
2581
+ "Host"
2582
+ ):
2583
+ self.send_error(403)
2584
+ return
2585
+ try:
2586
+ length = int(self.headers.get("Content-Length", "0"))
2587
+ if not 0 < length <= MAX_SOURCE_JUMP_BYTES:
2588
+ raise ValueError(length)
2589
+ request = json.loads(self.rfile.read(length))
2590
+ source = (PROJECT_ROOT / request["source"]).resolve()
2591
+ source.relative_to(PROJECT_ROOT.resolve())
2592
+ if source.suffix != ".qmd":
2593
+ raise ValueError("Expected a QMD source")
2594
+ phrase = request["phrase"]
2595
+ if not isinstance(phrase, str):
2596
+ raise ValueError("Expected source text")
2597
+ except (ValueError, KeyError, TypeError):
2598
+ self.send_error(400)
2599
+ return
2600
+ try:
2601
+ jump_to_source(source, phrase)
2602
+ except (
2603
+ OSError, subprocess.SubprocessError, ValueError
2604
+ ) as exc:
2605
+ print(f"pydifft source jump failed: {exc}", flush=True)
2606
+ self.send_error(500)
2607
+ return
2608
+ self.send_response(204)
2609
+ self.end_headers()
2610
+ return
2611
+ # }}}
2502
2612
  # {{{ Re-run the notebook behind a right-clicked SOURCE label
2503
2613
  if self.path != RERUN_ENDPOINT:
2504
2614
  self.send_error(404)
@@ -880,9 +880,14 @@ def wr(
880
880
  fp.write(result)
881
881
 
882
882
 
883
- def check_prose(content, wrapnumber, punctuation_slop, line_start=1):
883
+ def check_prose(
884
+ content, wrapnumber, punctuation_slop, line_start=1, overflow_words=0,
885
+ ):
884
886
  """Report source lines in `content` that break wr's rules.
885
887
 
888
+ Diff alignment can set overflow_words to tolerate that many words ending
889
+ beyond the character width. Ordinary wrapping checks use a strict limit.
890
+
886
891
  Checks mirror wrap_prose's own paragraph/sentence/macro
887
892
  splitting so the two commands never disagree about what counts as a
888
893
  sentence: lines must not hold more words than wr's own greedy choice
@@ -953,7 +958,18 @@ def check_prose(content, wrapnumber, punctuation_slop, line_start=1):
953
958
  + len(words[actual_end])
954
959
  - positions[fragment_offset]
955
960
  )
956
- if actual_end > wr_end:
961
+ # Diff alignment can keep a few extra words to
962
+ # avoid moving the sentence tail onto a new line.
963
+ # Count against the character limit, independently
964
+ # of an earlier preferred punctuation break.
965
+ excess = sum(
966
+ positions[j] + len(words[j])
967
+ - positions[fragment_offset] > wrapnumber
968
+ for j in range(fragment_offset, actual_end + 1)
969
+ )
970
+ if actual_end > wr_end and not (
971
+ overflow_words and 0 < excess <= overflow_words
972
+ ):
957
973
  overflow = words[wr_end + 1]
958
974
  rule = (
959
975
  "line too long"
@@ -1134,7 +1150,7 @@ def _mask_html_comments(content, initially_open=False):
1134
1150
 
1135
1151
 
1136
1152
  def markdown_lint_issues_from_text(
1137
- content, wrapnumber=45, punctuation_slop=20
1153
+ content, wrapnumber=45, punctuation_slop=20, overflow_words=0,
1138
1154
  ):
1139
1155
  """Return Markdown writing issues, including unfinished source spans."""
1140
1156
  masked_content = _mask_html_comments(content)
@@ -1149,7 +1165,9 @@ def markdown_lint_issues_from_text(
1149
1165
  for line, width, span in _iter_block_spans(
1150
1166
  markdown_blocks(prose_content), wrapnumber
1151
1167
  )
1152
- for issue in check_prose(span, width, punctuation_slop, line)
1168
+ for issue in check_prose(
1169
+ span, width, punctuation_slop, line, overflow_words,
1170
+ )
1153
1171
  ]
1154
1172
  issues.extend(
1155
1173
  (issue["line"], issue["message"])
@@ -1523,7 +1541,8 @@ def autofix_markdown_file(
1523
1541
  # issue at a time re-lints the whole file for every single fix.
1524
1542
  for _ in range(100):
1525
1543
  issues = markdown_lint_issues_from_text(
1526
- content, wrapnumber=wrapnumber, punctuation_slop=punctuation_slop
1544
+ content, wrapnumber=wrapnumber, punctuation_slop=punctuation_slop,
1545
+ overflow_words=4 if baseline is not None else 0,
1527
1546
  )
1528
1547
  fixable = {}
1529
1548
  for line_number, message in issues:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyDiffTools"
7
- version = "0.1.54"
7
+ version = "0.1.56"
8
8
  authors = [{ name = "J M Franck" }]
9
9
  description = "Diff tools"
10
10
  readme = "README.rst"
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import io
2
3
  import queue
3
4
  import socket
@@ -380,6 +381,7 @@ def test_second_qmdb_takes_next_ports_and_accepts_rebuild(
380
381
  build_calls = []
381
382
  http_ports = []
382
383
  handlers = []
384
+ jumps = []
383
385
  urls = []
384
386
  httpd = FakeHttpServer()
385
387
 
@@ -424,6 +426,20 @@ def test_second_qmdb_takes_next_ports_and_accepts_rebuild(
424
426
  handler.do_POST()
425
427
  assert responses == [202]
426
428
  assert built.wait(timeout=2)
429
+ handler.path = fast_build.SOURCE_JUMP_ENDPOINT
430
+ for source, status in [
431
+ ("doc.qmd", 204), ("../outside.qmd", 400),
432
+ ("doc.html", 400),
433
+ ]:
434
+ body = json.dumps({
435
+ "source": source, "phrase": "Source text."
436
+ }).encode()
437
+ handler.headers["Content-Length"] = str(len(body))
438
+ handler.rfile = io.BytesIO(body)
439
+ handler.send_error = responses.append
440
+ handler.do_POST()
441
+ assert responses[-1] == status
442
+ assert jumps == [(tmp_path / "doc.qmd", "Source text.")]
427
443
  return False
428
444
 
429
445
  class Observer:
@@ -445,6 +461,10 @@ def test_second_qmdb_takes_next_ports_and_accepts_rebuild(
445
461
  classmethod(lambda cls, **_k: Machine()),
446
462
  )
447
463
  monkeypatch.setattr(fast_build, "load_rendered_files", lambda: [])
464
+ monkeypatch.setattr(
465
+ fast_build, "jump_to_source",
466
+ lambda source, phrase: jumps.append((source, phrase)),
467
+ )
448
468
  monkeypatch.setattr(fast_build, "ThreadingHTTPServer", fake_http_server)
449
469
  monkeypatch.setattr(fast_build, "_serve_forever", lambda _httpd: None)
450
470
  monkeypatch.setattr(fast_build, "BrowserReloader", Reloader)