ctrl-kd 4.7.0__tar.gz → 4.7.1__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 (106) hide show
  1. {ctrl_kd-4.7.0/src/ctrl_kd.egg-info → ctrl_kd-4.7.1}/PKG-INFO +1 -1
  2. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1/src/ctrl_kd.egg-info}/PKG-INFO +1 -1
  3. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/__init__.py +1 -1
  4. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/pdf.py +91 -2
  5. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pn_page_timing.py +125 -0
  6. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/LICENSE +0 -0
  7. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/README.md +0 -0
  8. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/pyproject.toml +0 -0
  9. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/setup.cfg +0 -0
  10. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/SOURCES.txt +0 -0
  11. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/dependency_links.txt +0 -0
  12. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/entry_points.txt +0 -0
  13. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/top_level.txt +0 -0
  14. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/afm.py +0 -0
  15. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/cli.py +0 -0
  16. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/convert.py +0 -0
  17. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/core.py +0 -0
  18. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/emit.py +0 -0
  19. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/fontmap.py +0 -0
  20. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/info.py +0 -0
  21. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/layout.py +0 -0
  22. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/pictures.py +0 -0
  23. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/pix.py +0 -0
  24. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/piximg.py +0 -0
  25. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/LYING.WS +0 -0
  26. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/OCAPTAIN.WS +0 -0
  27. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/TWAINLET.WS +0 -0
  28. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/WARPRAYR.WS +0 -0
  29. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/symbolmap.py +0 -0
  30. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/typestyles.py +0 -0
  31. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/writer.py +0 -0
  32. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/wschange.py +0 -0
  33. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_bare_tab_modulus8.py +0 -0
  34. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_ctrlkd.py +0 -0
  35. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_dot_comment_prints_nothing.py +0 -0
  36. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_driver_substitutions_exports.py +0 -0
  37. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_endnote_leading_gap.py +0 -0
  38. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_fidelity_gate.py +0 -0
  39. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_flags_toc_inline.py +0 -0
  40. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_form_feed_line_marks.py +0 -0
  41. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_glyph_aspect.py +0 -0
  42. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_graphic_cell_ops.py +0 -0
  43. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_graphic_cells.py +0 -0
  44. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_head_foot_timing.py +0 -0
  45. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_html_print_stylesheet.py +0 -0
  46. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_justification.py +0 -0
  47. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_layout_marks.py +0 -0
  48. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_bullet_glyph.py +0 -0
  49. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_char_substitution.py +0 -0
  50. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_colour_restore.py +0 -0
  51. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_heading_face.py +0 -0
  52. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_hp_patterns.py +0 -0
  53. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_legend_line_spacing.py +0 -0
  54. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_pcl_rectangles.py +0 -0
  55. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_shading_table_rules.py +0 -0
  56. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_table_rule_weight.py +0 -0
  57. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_load_plugins.py +0 -0
  58. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_mailmerge_data_files.py +0 -0
  59. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_merge_page_number_variable.py +0 -0
  60. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_blank_after_tightened_line.py +0 -0
  61. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_box_regions.py +0 -0
  62. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_centring_clip.py +0 -0
  63. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_line_spacing.py +0 -0
  64. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_lint.py +0 -0
  65. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_page_baseline.py +0 -0
  66. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_rtf_structure_rows.py +0 -0
  67. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_verse_defrow.py +0 -0
  68. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_note_rulings_20260824.py +0 -0
  69. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_page_parity.py +0 -0
  70. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_paper_verdicts.py +0 -0
  71. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_fidelity.py +0 -0
  72. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_page_membership.py +0 -0
  73. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_page_size_eject.py +0 -0
  74. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_tolerance.py +0 -0
  75. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_transparent_print.py +0 -0
  76. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_v4_column_geometry.py +0 -0
  77. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_v4_untriaged_fixes.py +0 -0
  78. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_vertical_wraparound.py +0 -0
  79. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_peseta_euro_driver.py +0 -0
  80. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pf_print_reformat.py +0 -0
  81. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pictures.py +0 -0
  82. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pix.py +0 -0
  83. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_polarity_gate.py +0 -0
  84. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_print_control_labels_invisible.py +0 -0
  85. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_printed_fidelity.py +0 -0
  86. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_facing_pages.py +0 -0
  87. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_head_style_attrs.py +0 -0
  88. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_keep_with_next.py +0 -0
  89. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_page_numbers.py +0 -0
  90. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_section_spine.py +0 -0
  91. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_samples.py +0 -0
  92. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_sawyer_corpus.py +0 -0
  93. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_screenplay_detection.py +0 -0
  94. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_screenplay_pdf.py +0 -0
  95. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_screenplay_rendering.py +0 -0
  96. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_sentence_spacing_n9.py +0 -0
  97. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_structural_checks.py +0 -0
  98. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_style_leading.py +0 -0
  99. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_symbol_span_graphic_chars.py +0 -0
  100. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_trailing_pa_break.py +0 -0
  101. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_verse_quote_couplet.py +0 -0
  102. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_verse_spacing.py +0 -0
  103. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_width_and_cp1252_memo.py +0 -0
  104. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_writer.py +0 -0
  105. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_wschange.py +0 -0
  106. {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_zero_width_tab.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrl-kd
3
- Version: 4.7.0
3
+ Version: 4.7.1
4
4
  Summary: Convert WordStar 4-7 documents and print-to-disk files to text, Markdown, HTML, RTF, or PDF. ^KD: save and done.
5
5
  Author: Jon Michaels
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrl-kd
3
- Version: 4.7.0
3
+ Version: 4.7.1
4
4
  Summary: Convert WordStar 4-7 documents and print-to-disk files to text, Markdown, HTML, RTF, or PDF. ^KD: save and done.
5
5
  Author: Jon Michaels
6
6
  License: MIT
@@ -8,4 +8,4 @@ from .pdf import emit_pdf # registers the 'pdf' format
8
8
  from .convert import convert, select_notes, DEFAULT_NOTE_KINDS, ALL_NOTE_KINDS
9
9
  from .info import document_info
10
10
 
11
- __version__ = '4.7.0'
11
+ __version__ = '4.7.1'
@@ -750,7 +750,21 @@ def _checkpoints_by_page(checkpoints, pages):
750
750
  trailing blanks: a page of nothing but blank lines ends up empty, with
751
751
  no `bi` on it at all, which is precisely the shape a leading blank run
752
752
  makes. A page that never got one (a synthetic or degenerate page)
753
- inherits the last real position rather than resetting the walk."""
753
+ inherits the last real position rather than resetting the walk.
754
+
755
+ A PAGE WITH NO POSITION AT ALL -- not even an inherited one, because no
756
+ earlier page had one either -- FALLS BACK TO THE BLOCK-RANGE RULE, the
757
+ granularity this walk replaced on 2026-09-14: the last checkpoint whose
758
+ block index is at or before the highest block index this page carries.
759
+ Leaving it on the seeded checkpoint 0 instead silently answered "the
760
+ document's opening default" for a page that plainly reads further in,
761
+ and the document's own `.op` was never consulted: `samples/LYING.WS`
762
+ numbered all three of its pages where real WordStar 7 numbers none of
763
+ them, because the footnote paginator sets no `read_pos` and LYING's
764
+ `.op` is checkpoint 1 (research: "Why real WS7 prints no page number on
765
+ some documents", 2026-09-15). Never moves BACKWARD -- the walk's
766
+ consumers (`_resolve_page_numbers`' re-anchor test) read a falling index
767
+ as a new anchor."""
754
768
  out, last, pos = [], 0, None
755
769
  for pg in pages:
756
770
  pos = getattr(pg, 'read_pos', None) or pos
@@ -761,10 +775,38 @@ def _checkpoints_by_page(checkpoints, pages):
761
775
  if not (cp_bi < pos_bi or (cp_bi == pos_bi and cp_li < pos_count)):
762
776
  break
763
777
  last = idx
778
+ else:
779
+ last = max(last, _checkpoint_by_block(checkpoints, pg))
764
780
  out.append(last)
765
781
  return out
766
782
 
767
783
 
784
+ def _checkpoint_by_block(checkpoints, pg):
785
+ """The BLOCK-RANGE answer for one page: the index of the last checkpoint
786
+ whose block index is at or before the highest block index the page
787
+ carries -- `_pgnum_at`/`_pl_at`'s own "last checkpoint at or before this
788
+ block wins" contract, as an index rather than a value so the positional
789
+ walk above can keep using it as a floor.
790
+
791
+ A page carrying no `.bi` at all (nothing but emitter-made lines -- a
792
+ footnote-area-only page, a blank page whose trailing blanks were
793
+ stripped) has no block range to test, so it keeps whatever the walk
794
+ already had."""
795
+ top = None
796
+ for ln in pg:
797
+ bi = getattr(ln, 'bi', None)
798
+ if bi is not None and (top is None or bi > top):
799
+ top = bi
800
+ if top is None:
801
+ return 0
802
+ last = 0
803
+ for idx in range(1, len(checkpoints)):
804
+ if checkpoints[idx][0] > top:
805
+ break
806
+ last = idx
807
+ return last
808
+
809
+
768
810
  # no `\b` after the 2-letter code (matches `_PN_CMD_RE`'s own shape,
769
811
  # immediately below in this same module): a real WS7 file overwhelmingly
770
812
  # writes `.pn0`/`.pn22`/`.hm3` with NO space before the argument, and `\b`
@@ -3795,6 +3837,18 @@ def _paginate_printed_notes(doc, cap, width, pix_results=None, pictures='off',
3795
3837
  # footnote AREA or with plain BODY text -- `_endnote_pages` needs the
3796
3838
  # distinction to decide whether a blank line precedes the first endnote.
3797
3839
  last_page_has_area = False
3840
+ # The same running per-block line tally `_doc_to_pagelines`' paginator
3841
+ # keeps, for the same consumer: `_checkpoints_by_page` asks each page
3842
+ # HOW FAR INTO THE DOCUMENT it had read when it closed, and a page built
3843
+ # here never answered -- so a document with footnotes lost its own
3844
+ # `.op`/`.pn`/`.pg` and took the seeded "numbering ON" default.
3845
+ # `samples/LYING.WS` numbered all three pages where real WS7 numbers
3846
+ # none (research: "Why real WS7 prints no page number on some
3847
+ # documents", 2026-09-15). Counted off the BODY only: a footnote area's
3848
+ # lines are the emitter's own, and the reference that pulled them down
3849
+ # here was already counted on the body line carrying it.
3850
+ read_tally = {}
3851
+ read_pos = None
3798
3852
  i, n = 0, len(stream)
3799
3853
  while i < n:
3800
3854
  body, entries, is_terminal = [], [], False
@@ -3838,6 +3892,10 @@ def _paginate_printed_notes(doc, cap, width, pix_results=None, pictures='off',
3838
3892
  if body and body_len + cost + _area_size(entries) > _col_cap():
3839
3893
  break # natural page-full: line moves on
3840
3894
  body.append(spans)
3895
+ bi_here = getattr(spans, 'bi', None)
3896
+ if bi_here is not None:
3897
+ read_tally[bi_here] = read_tally.get(bi_here, 0) + 1
3898
+ read_pos = (bi_here, read_tally[bi_here])
3841
3899
  body_len += cost
3842
3900
  if i == last_idx:
3843
3901
  is_terminal = True
@@ -3886,7 +3944,9 @@ def _paginate_printed_notes(doc, cap, width, pix_results=None, pictures='off',
3886
3944
  override = target_first - body_y
3887
3945
  if override > 0:
3888
3946
  area = [PageLine(area[0], lead=override)] + area[1:]
3889
- pages.append(body + area)
3947
+ closed = NotesPage(body + area)
3948
+ closed.read_pos = read_pos
3949
+ pages.append(closed)
3890
3950
  _advance_column()
3891
3951
  last_page_cost = body_len + _area_size(entries)
3892
3952
  last_page_has_area = bool(entries)
@@ -4182,6 +4242,26 @@ class PageLine(list):
4182
4242
  self.graphic_cells = graphic_cells
4183
4243
 
4184
4244
 
4245
+ class NotesPage(list):
4246
+ """A page built by `_paginate_printed_notes`, carrying ONE thing beyond
4247
+ a plain list: the `read_pos` `_checkpoints_by_page` needs.
4248
+
4249
+ Deliberately NOT a `Page`. `Page.__init__` seeds `headers`/`footers` to
4250
+ `{}`, and `_emit_pdf_inner` reads an empty dict as an authoritative "this
4251
+ page has no running head" rather than "no opinion, use the document's"
4252
+ (see `_apply_columns`' own `merged.headers` comment) -- so promoting the
4253
+ notes paginator's plain lists to `Page` would drop a real running head
4254
+ from every footnote-bearing document. This subclass leaves every other
4255
+ attribute undefined, so each `getattr(pg, ..., default)` in the emitters
4256
+ still lands on exactly the default a bare list did."""
4257
+
4258
+ __slots__ = ('read_pos',)
4259
+
4260
+ def __init__(self, seq=()):
4261
+ super().__init__(seq)
4262
+ self.read_pos = None
4263
+
4264
+
4185
4265
  class Page(list):
4186
4266
  """One paginated page: a list of PageLines plus the running head and
4187
4267
  foot IN FORCE when this page printed (replayed from doc.hf_events).
@@ -6830,9 +6910,18 @@ def _attach_head_foot_lines_printed(doc, pages, size):
6830
6910
  # reads this SAME `pages` list a few lines later -- keeps
6831
6911
  # seeing byte-identical `getattr(pl, 'headers', None)`
6832
6912
  # answers; PDF bytes do not move.
6913
+ # `read_pos` carried across too (2026-09-15): the notes
6914
+ # paginator now records one, and `_checkpoints_by_page` is
6915
+ # asked the automatic-number question a SECOND time by
6916
+ # `_emit_pdf_inner`'s own render loop, off this very list.
6917
+ # Dropping the position here would let the two answers
6918
+ # disagree about where a `.op`/`.pn` was read -- the same
6919
+ # two-sources-of-truth fault this promotion exists to close.
6920
+ was = getattr(pg, 'read_pos', None)
6833
6921
  pg = Page(pg)
6834
6922
  pg.headers = None
6835
6923
  pg.footers = None
6924
+ pg.read_pos = was
6836
6925
  pages[page_index] = pg
6837
6926
  if resolved['headers']:
6838
6927
  # `style_attrs` (planning #255) is a PDF-render-time concern
@@ -166,3 +166,128 @@ def test_the_checkpoints_carry_their_line_position():
166
166
  cps = pdf._pn_checkpoints(doc)
167
167
  assert [c[2] for c in cps][-2:] == [3, 7], cps
168
168
  assert cps[-1][1] > cps[-2][1], cps # the later one read further in
169
+
170
+
171
+ # ====== the footnote path reads the document's own `.op` (2026-09-15) ======
172
+ #
173
+ # Research: "Why real WS7 prints no page number on some documents"
174
+ # (jon_vault, 2026-09-15). Real WordStar 7 numbers every page unless the
175
+ # document says otherwise, and `.op` is one of the four things that say
176
+ # otherwise. `samples/LYING.WS` carries `.op` AND a footnote, and the
177
+ # positional walk this file's own Q12 tests introduced printed a number on
178
+ # all three of its pages: a page built by the FOOTNOTE paginator got no
179
+ # `read_pos`, the walk's inner loop never ran, and the answer stayed on the
180
+ # seeded "numbering ON" checkpoint 0 -- the `.op` was never consulted.
181
+ # Across all 308 WS7 captures no document carrying `.op` prints a number,
182
+ # with zero counter-examples, and a whole-corpus sweep finds exactly three
183
+ # documents carrying both a footnote and a numbering-off command (LYING,
184
+ # and two private ones), so these are the whole blast radius.
185
+
186
+ import os
187
+
188
+ SAMPLES_DIR = os.path.join(
189
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'samples')
190
+
191
+ # WordStar's own default geometry again, but with no `.ps off`: a document
192
+ # whose ONLY dot command is the one under test, so nothing else can be
193
+ # blamed for the answer.
194
+ PLAIN = b'.pl 66' + HARD + b'.mt 3' + HARD + b'.mb 8' + HARD + b'.po1i' + HARD
195
+
196
+
197
+ def _ws7_block(cmd, content=b''):
198
+ """One WS7 0x1D-delimited block -- the shape
199
+ tests/test_note_rulings_20260824.py already builds notes with."""
200
+ count = (len(content) + 4).to_bytes(2, 'little')
201
+ return b'\x1d' + count + bytes([cmd]) + content + count + b'\x1d'
202
+
203
+
204
+ def _footnote(text, number=1):
205
+ """A one-line FOOTNOTE (note command 0x03) -- the kind that sends a
206
+ document down `_paginate_printed_notes`, which is the paginator that
207
+ lost the `.op`."""
208
+ return _ws7_block(0x03, (1).to_bytes(2, 'little')
209
+ + number.to_bytes(2, 'little') + bytes([0x30]) + text)
210
+
211
+
212
+ def _foot_digits(data):
213
+ """Per page, the digit-only chunks drawn on that page's LOWEST print
214
+ line -- the research note's own install-independent test for "is there
215
+ an automatic page number here", which assumes no fixed x or y (`.pl`,
216
+ `.mb`, `.po` and `.pc` all move the real one)."""
217
+ out = pdf.emit_pdf(core.parse_ws(data), mode='printed')
218
+ pages = []
219
+ for stream in re.findall(rb'stream\n(.*?)endstream', out, re.S):
220
+ rows = {}
221
+ for x, y, t in re.findall(rb'([\d.]+) ([\d.]+) Td\s*\((.*?)\) Tj', stream):
222
+ rows.setdefault(float(y), []).append(t.decode('latin-1'))
223
+ if not rows:
224
+ pages.append([])
225
+ continue
226
+ foot = rows[min(rows)] # PDF y grows UPWARD: lowest line
227
+ pages.append([t for t in foot if t.strip().isdigit()])
228
+ return pages
229
+
230
+
231
+ def test_lying_ws_prints_no_automatic_page_number_on_any_page():
232
+ """The regression itself, pinned on the bundled public-domain sample.
233
+ LYING.WS's `.op` is the FIRST LINE OF THE FILE -- it follows the header
234
+ block's closing 0x1D with no CR LF before it -- and it turns the
235
+ automatic number off for the whole document. Real WS7's own capture
236
+ (ws7-prints/v3) prints no number on any of the three pages."""
237
+ data = open(os.path.join(SAMPLES_DIR, 'LYING.WS'), 'rb').read()
238
+ doc = core.parse_ws(data)
239
+ assert doc.notes, 'LYING.WS is the fixture BECAUSE it has a footnote'
240
+ pages = pdf._doc_to_pagelines(doc, printed=True)
241
+ on = pdf._pgnum_by_page(pdf._pgnum_checkpoints(doc), pages)
242
+ assert on == [False] * len(pages), on
243
+ assert _foot_digits(data) == [[], [], []], _foot_digits(data)
244
+
245
+
246
+ def test_a_footnote_document_that_omits_numbers_reads_its_own_op():
247
+ """The same shape synthetically, so the rule is pinned even if the
248
+ sample ever changes: `.op`, a footnote, two pages of body."""
249
+ data = (PLAIN + b'.op' + HARD + b'OPBODY' + _footnote(b'A footnote.')
250
+ + HARD + _blanks(80) + b'OPTAIL' + HARD)
251
+ assert _foot_digits(data) == [[], []], _foot_digits(data)
252
+
253
+
254
+ def test_a_footnote_document_with_pn_is_still_numbered():
255
+ """The control, and the half a too-broad fix would break: footnotes have
256
+ NOTHING to do with the automatic number. 28 numbered WS7 captures carry
257
+ real footnotes. `.pn` says number the pages, and it still does."""
258
+ data = (PLAIN + b'.pn 1' + HARD + b'PNBODY' + _footnote(b'A footnote.')
259
+ + HARD + _blanks(80) + b'PNTAIL' + HARD)
260
+ assert _foot_digits(data) == [['1'], ['2']], _foot_digits(data)
261
+
262
+
263
+ def test_the_footnote_paginator_records_where_each_page_read_to():
264
+ """The first half of the mechanism: a page built by the notes-aware
265
+ paginator now carries its own `read_pos`, so it answers the walk
266
+ directly instead of falling through to the seeded default."""
267
+ for name, dot in (('LYING.WS', None), (None, b'.pn 1'), (None, b'.op')):
268
+ if name:
269
+ data = open(os.path.join(SAMPLES_DIR, name), 'rb').read()
270
+ else:
271
+ data = (PLAIN + dot + HARD + b'XBODY' + _footnote(b'A footnote.')
272
+ + HARD + _blanks(80) + b'XTAIL' + HARD)
273
+ doc = core.parse_ws(data)
274
+ assert pdf._has_placeable_notes(doc), (name, dot)
275
+ positions = [getattr(pg, 'read_pos', None)
276
+ for pg in pdf._doc_to_pagelines(doc, printed=True)]
277
+ assert all(p is not None for p in positions), (name, dot, positions)
278
+ assert positions == sorted(positions), (name, dot, positions)
279
+
280
+
281
+ def test_a_page_with_no_read_position_falls_back_to_the_block_range_rule():
282
+ """The second half, and the belt to that brace: a page that carries no
283
+ read position at all -- a synthetic or degenerate page, from any future
284
+ paginator -- is answered by block range (the last checkpoint at or
285
+ before the highest block the page carries), not by the seeded
286
+ checkpoint 0. Checkpoints here: ON at block 0, OFF at block 2."""
287
+ checkpoints = [(0, 0, True), (2, 0, False)]
288
+ early, late = pdf.PageLine([('x', None)]), pdf.PageLine([('y', None)])
289
+ early.bi, late.bi = 1, 3
290
+ assert pdf._checkpoints_by_page(checkpoints, [[early], [late]]) == [0, 1]
291
+ assert pdf._checkpoints_by_page(checkpoints, [[late]]) == [1]
292
+ # No `.bi` anywhere on the page: nothing to range over, walk unchanged.
293
+ assert pdf._checkpoints_by_page(checkpoints, [[]]) == [0]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes