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.
- {ctrl_kd-4.7.0/src/ctrl_kd.egg-info → ctrl_kd-4.7.1}/PKG-INFO +1 -1
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1/src/ctrl_kd.egg-info}/PKG-INFO +1 -1
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/__init__.py +1 -1
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/pdf.py +91 -2
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pn_page_timing.py +125 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/LICENSE +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/README.md +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/pyproject.toml +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/setup.cfg +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/SOURCES.txt +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/dependency_links.txt +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/entry_points.txt +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrl_kd.egg-info/top_level.txt +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/afm.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/cli.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/convert.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/core.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/emit.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/fontmap.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/info.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/layout.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/pictures.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/pix.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/piximg.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/LYING.WS +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/OCAPTAIN.WS +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/TWAINLET.WS +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/samples/WARPRAYR.WS +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/symbolmap.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/typestyles.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/writer.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/src/ctrlkd/wschange.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_bare_tab_modulus8.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_ctrlkd.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_dot_comment_prints_nothing.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_driver_substitutions_exports.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_endnote_leading_gap.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_fidelity_gate.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_flags_toc_inline.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_form_feed_line_marks.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_glyph_aspect.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_graphic_cell_ops.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_graphic_cells.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_head_foot_timing.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_html_print_stylesheet.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_justification.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_layout_marks.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_bullet_glyph.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_char_substitution.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_colour_restore.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_heading_face.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_hp_patterns.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_legend_line_spacing.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_pcl_rectangles.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_shading_table_rules.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_lj6dtp_table_rule_weight.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_load_plugins.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_mailmerge_data_files.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_merge_page_number_variable.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_blank_after_tightened_line.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_box_regions.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_centring_clip.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_line_spacing.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_lint.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_page_baseline.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_rtf_structure_rows.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_modern_verse_defrow.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_note_rulings_20260824.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_page_parity.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_paper_verdicts.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_fidelity.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_page_membership.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_page_size_eject.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_tolerance.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_transparent_print.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_v4_column_geometry.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_v4_untriaged_fixes.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pcl_vertical_wraparound.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_peseta_euro_driver.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pf_print_reformat.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pictures.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_pix.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_polarity_gate.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_print_control_labels_invisible.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_printed_fidelity.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_facing_pages.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_head_style_attrs.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_keep_with_next.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_page_numbers.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_rtf_section_spine.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_samples.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_sawyer_corpus.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_screenplay_detection.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_screenplay_pdf.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_screenplay_rendering.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_sentence_spacing_n9.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_structural_checks.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_style_leading.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_symbol_span_graphic_chars.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_trailing_pa_break.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_verse_quote_couplet.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_verse_spacing.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_width_and_cp1252_memo.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_writer.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_wschange.py +0 -0
- {ctrl_kd-4.7.0 → ctrl_kd-4.7.1}/tests/test_zero_width_tab.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|