ctrl-kd 4.7.1__tar.gz → 4.8.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 (112) hide show
  1. {ctrl_kd-4.7.1/src/ctrl_kd.egg-info → ctrl_kd-4.8.1}/PKG-INFO +1 -1
  2. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1/src/ctrl_kd.egg-info}/PKG-INFO +1 -1
  3. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrl_kd.egg-info/SOURCES.txt +6 -0
  4. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/__init__.py +1 -1
  5. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/cli.py +10 -7
  6. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/core.py +132 -4
  7. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/emit.py +189 -54
  8. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/layout.py +97 -8
  9. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/pdf.py +920 -105
  10. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/symbolmap.py +40 -3
  11. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_ctrlkd.py +256 -22
  12. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_graphic_cells.py +2 -2
  13. ctrl_kd-4.8.1/tests/test_head_foot_placement_m16.py +146 -0
  14. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_head_foot_timing.py +91 -0
  15. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_justification.py +1 -1
  16. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_merge_page_number_variable.py +8 -1
  17. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_centring_clip.py +11 -0
  18. ctrl_kd-4.8.1/tests/test_modern_columns.py +379 -0
  19. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_line_spacing.py +25 -2
  20. ctrl_kd-4.8.1/tests/test_modern_page_number.py +344 -0
  21. ctrl_kd-4.8.1/tests/test_modern_sheet_height.py +137 -0
  22. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_verse_defrow.py +32 -4
  23. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_tolerance.py +97 -0
  24. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_v4_column_geometry.py +1 -1
  25. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pn_page_timing.py +33 -0
  26. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_printed_fidelity.py +143 -8
  27. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_rtf_section_spine.py +25 -15
  28. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_structural_checks.py +2 -4
  29. ctrl_kd-4.8.1/tests/test_style_strikeout_runs_until_cleared.py +297 -0
  30. ctrl_kd-4.8.1/tests/test_style_symbol_map_resolved_face.py +173 -0
  31. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_symbol_span_graphic_chars.py +30 -12
  32. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/LICENSE +0 -0
  33. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/README.md +0 -0
  34. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/pyproject.toml +0 -0
  35. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/setup.cfg +0 -0
  36. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrl_kd.egg-info/dependency_links.txt +0 -0
  37. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrl_kd.egg-info/entry_points.txt +0 -0
  38. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrl_kd.egg-info/top_level.txt +0 -0
  39. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/afm.py +0 -0
  40. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/convert.py +0 -0
  41. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/fontmap.py +0 -0
  42. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/info.py +0 -0
  43. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/pictures.py +0 -0
  44. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/pix.py +0 -0
  45. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/piximg.py +0 -0
  46. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/samples/LYING.WS +0 -0
  47. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/samples/OCAPTAIN.WS +0 -0
  48. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/samples/TWAINLET.WS +0 -0
  49. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/samples/WARPRAYR.WS +0 -0
  50. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/typestyles.py +0 -0
  51. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/writer.py +0 -0
  52. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/src/ctrlkd/wschange.py +0 -0
  53. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_bare_tab_modulus8.py +0 -0
  54. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_dot_comment_prints_nothing.py +0 -0
  55. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_driver_substitutions_exports.py +0 -0
  56. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_endnote_leading_gap.py +0 -0
  57. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_fidelity_gate.py +0 -0
  58. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_flags_toc_inline.py +0 -0
  59. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_form_feed_line_marks.py +0 -0
  60. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_glyph_aspect.py +0 -0
  61. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_graphic_cell_ops.py +0 -0
  62. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_html_print_stylesheet.py +0 -0
  63. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_layout_marks.py +0 -0
  64. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_bullet_glyph.py +0 -0
  65. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_char_substitution.py +0 -0
  66. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_colour_restore.py +0 -0
  67. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_heading_face.py +0 -0
  68. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_hp_patterns.py +0 -0
  69. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_legend_line_spacing.py +0 -0
  70. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_pcl_rectangles.py +0 -0
  71. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_shading_table_rules.py +0 -0
  72. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_lj6dtp_table_rule_weight.py +0 -0
  73. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_load_plugins.py +0 -0
  74. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_mailmerge_data_files.py +0 -0
  75. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_blank_after_tightened_line.py +0 -0
  76. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_box_regions.py +0 -0
  77. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_lint.py +0 -0
  78. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_page_baseline.py +0 -0
  79. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_modern_rtf_structure_rows.py +0 -0
  80. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_note_rulings_20260824.py +0 -0
  81. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_page_parity.py +0 -0
  82. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_paper_verdicts.py +0 -0
  83. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_fidelity.py +0 -0
  84. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_page_membership.py +0 -0
  85. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_page_size_eject.py +0 -0
  86. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_transparent_print.py +0 -0
  87. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_v4_untriaged_fixes.py +0 -0
  88. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pcl_vertical_wraparound.py +0 -0
  89. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_peseta_euro_driver.py +0 -0
  90. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pf_print_reformat.py +0 -0
  91. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pictures.py +0 -0
  92. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_pix.py +0 -0
  93. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_polarity_gate.py +0 -0
  94. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_print_control_labels_invisible.py +0 -0
  95. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_rtf_facing_pages.py +0 -0
  96. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_rtf_head_style_attrs.py +0 -0
  97. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_rtf_keep_with_next.py +0 -0
  98. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_rtf_page_numbers.py +0 -0
  99. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_samples.py +0 -0
  100. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_sawyer_corpus.py +0 -0
  101. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_screenplay_detection.py +0 -0
  102. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_screenplay_pdf.py +0 -0
  103. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_screenplay_rendering.py +0 -0
  104. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_sentence_spacing_n9.py +0 -0
  105. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_style_leading.py +0 -0
  106. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_trailing_pa_break.py +0 -0
  107. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_verse_quote_couplet.py +0 -0
  108. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_verse_spacing.py +0 -0
  109. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_width_and_cp1252_memo.py +0 -0
  110. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_writer.py +0 -0
  111. {ctrl_kd-4.7.1 → ctrl_kd-4.8.1}/tests/test_wschange.py +0 -0
  112. {ctrl_kd-4.7.1 → ctrl_kd-4.8.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.1
3
+ Version: 4.8.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.1
3
+ Version: 4.8.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
@@ -38,6 +38,7 @@ tests/test_form_feed_line_marks.py
38
38
  tests/test_glyph_aspect.py
39
39
  tests/test_graphic_cell_ops.py
40
40
  tests/test_graphic_cells.py
41
+ tests/test_head_foot_placement_m16.py
41
42
  tests/test_head_foot_timing.py
42
43
  tests/test_html_print_stylesheet.py
43
44
  tests/test_justification.py
@@ -57,10 +58,13 @@ tests/test_merge_page_number_variable.py
57
58
  tests/test_modern_blank_after_tightened_line.py
58
59
  tests/test_modern_box_regions.py
59
60
  tests/test_modern_centring_clip.py
61
+ tests/test_modern_columns.py
60
62
  tests/test_modern_line_spacing.py
61
63
  tests/test_modern_lint.py
62
64
  tests/test_modern_page_baseline.py
65
+ tests/test_modern_page_number.py
63
66
  tests/test_modern_rtf_structure_rows.py
67
+ tests/test_modern_sheet_height.py
64
68
  tests/test_modern_verse_defrow.py
65
69
  tests/test_note_rulings_20260824.py
66
70
  tests/test_page_parity.py
@@ -94,6 +98,8 @@ tests/test_screenplay_rendering.py
94
98
  tests/test_sentence_spacing_n9.py
95
99
  tests/test_structural_checks.py
96
100
  tests/test_style_leading.py
101
+ tests/test_style_strikeout_runs_until_cleared.py
102
+ tests/test_style_symbol_map_resolved_face.py
97
103
  tests/test_symbol_span_graphic_chars.py
98
104
  tests/test_trailing_pa_break.py
99
105
  tests/test_verse_quote_couplet.py
@@ -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.1'
11
+ __version__ = '4.8.1'
@@ -130,15 +130,17 @@ def main(argv=None):
130
130
  help='omit paragraph-style pass-through (HTML classes + '
131
131
  'generated CSS, RTF stylesheet) from the output')
132
132
  ap.add_argument('--headers', choices=('on', 'off'), default='on',
133
- help='headers, footers, and page numbers in the paged '
134
- 'surfaces (Printed/Native PDF and RTF). Default: on')
133
+ help="the document's own running heads and feet (.he/.fo) "
134
+ 'in the paged surfaces -- Printed PDF, Modern PDF and '
135
+ "both RTF modes. WordStar's own automatic page number "
136
+ 'is --page-numbers\' business alone. Default: on')
135
137
  ap.add_argument('--page-numbers', choices=('auto', 'on', 'off'),
136
138
  default='auto',
137
139
  help="WordStar's own AUTOMATIC page number -- the one "
138
140
  '.pc positions, a separate mechanism from a # the '
139
- 'author placed inside a real .he/.fo (that always '
140
- 'prints, unaffected by this flag). Printed PDF '
141
- 'and both RTF modes. auto (DEFAULT): the '
141
+ 'author placed inside a real .he/.fo (that goes '
142
+ 'with its head, under --headers, never this flag). '
143
+ 'Every paged surface. auto (DEFAULT): the '
142
144
  'document\'s own dot '
143
145
  'commands decide -- .pn/.pg turn it on, .op turns '
144
146
  'it off, exactly like real WordStar; a document '
@@ -149,8 +151,9 @@ def main(argv=None):
149
151
  'silent document. off: suppress it unconditionally. '
150
152
  'A declared footer always pre-empts it, in every '
151
153
  'mode (WSFORMAT.WS: "active only when the footers '
152
- 'are not in use"). --headers off also suppresses '
153
- 'it, per --headers\' own documented scope.')
154
+ 'are not in use") -- a property of the document, so '
155
+ '--headers off does not lift it, and does not '
156
+ 'suppress the number either.')
154
157
  ap.add_argument('--line-numbers', choices=('on', 'off'), default='on',
155
158
  help="the document's own .l# line-number gutter in the "
156
159
  'paged surfaces (Printed/Native PDF and RTF); no '
@@ -1427,6 +1427,46 @@ class Document:
1427
1427
  # can `|` it into a run's own styles unconditionally.
1428
1428
  header_style_attrs: dict = field(default_factory=dict)
1429
1429
  footer_style_attrs: dict = field(default_factory=dict)
1430
+ # M16 (2026-09-15, WS7 probes B0-B5 against sawyer/REF/BOOKLET.WS): the
1431
+ # RIGHT MARGIN the head/foot line's own style declares, in print
1432
+ # columns at 10 CPI (`right_margin_hmi / 180`), or absent when the
1433
+ # style declares none. `{line: cols}`.
1434
+ #
1435
+ # A right- or centre-aligned running head aligns against THIS, measured
1436
+ # from `.po` -- not against the document's `.rm`, not against the
1437
+ # column grid, and not against the sheet. Measured, five probes printed
1438
+ # by real WS7 under DOSBox-X: turning `.co 2` off (B2) and changing it
1439
+ # to `.co 3` with a `.rm` of 2.50" instead of 4.50" (B3) both leave
1440
+ # BOOKLET.WS's "Header Odd 1" at exactly the x the unmodified document
1441
+ # prints it at, while moving `.po` from 0.2in to 1.2in (B4) moves it by
1442
+ # exactly 1.00in. Its style's own `right_margin_hmi` is 18000 = 100
1443
+ # columns = 10.00in, and `.po` 0.2in + 10.00in = 10.20in is where the
1444
+ # last glyph of "Header Odd 1" ends, to 0.5pt.
1445
+ #
1446
+ # Every other document in the corpus with a right-aligned head declares
1447
+ # a style right margin EQUAL to its own `.rm` (a "Header at Top of
1448
+ # Page" style at 11700 = 6.50in beside a `.rm 65`), which is why the
1449
+ # `.rm` reading was right everywhere else and wrong here.
1450
+ header_style_rm: dict = field(default_factory=dict)
1451
+ footer_style_rm: dict = field(default_factory=dict)
1452
+ # M16: the LINE HEIGHT in force where this head/foot line was DEFINED,
1453
+ # in 48ths of an inch -- the step between successive running-head
1454
+ # lines, and 0 when the document asked for zero. `{line: lh_48}`.
1455
+ #
1456
+ # Measured (probe B1): removing BOOKLET.WS's own `.lh 0` -- which sits
1457
+ # immediately before its `.f1`/`.h1`/`.h2` and is cancelled by a
1458
+ # `.lh12` immediately after them -- makes real WS7 print its two header
1459
+ # lines on two rows 1/6in apart instead of both on one row, with the
1460
+ # columns left in place. Turning the columns off instead (B2) leaves
1461
+ # them on one row. So the collapse is the line height's doing, not the
1462
+ # column grid's, and it is the value in force at the COMMAND (the
1463
+ # `.lh12` that follows governs the body and not these lines).
1464
+ #
1465
+ # The block is anchored on its LAST line either way (probe B5: a
1466
+ # one-line head prints on the same row the second of two occupies), so
1467
+ # a zero step stacks every line onto that row.
1468
+ header_leads: dict = field(default_factory=dict)
1469
+ footer_leads: dict = field(default_factory=dict)
1430
1470
  # Planning #250: `.H1E`/`.H1O`/`.F1E`/`.F1O` (WSFORMAT.TXT: ".HE can
1431
1471
  # optionally specify even or odd numbered page headers" / the matching
1432
1472
  # ".FO" sentence -- confirmed the ONLY two lines that do; `.H2`-`.H5`/
@@ -3273,7 +3313,18 @@ def _parse_format_dot(cmd: bytes, state: dict) -> None:
3273
3313
  resolved = _resolve_lh_arg(value, m.group(2))
3274
3314
  if resolved is not None: # junk/non-positive: state stands
3275
3315
  state['lead_48'] = resolved
3316
+ state['hf_lead_48'] = resolved
3276
3317
  state['lh_auto'] = False
3318
+ elif value == 0:
3319
+ # M16 (2026-09-15): `.lh 0` leaves `lead_48` alone --
3320
+ # a zero body leading is not something any renderer or
3321
+ # the pagination arithmetic can use, and no evidence
3322
+ # says WordStar applies it to the body -- but it IS
3323
+ # what a running head's own line step becomes, proved
3324
+ # by probes B0/B1/B2 against real WS7 (see
3325
+ # `Document.header_leads`). Recorded on its own key so
3326
+ # the head can read it and nothing else changes.
3327
+ state['hf_lead_48'] = 0.0
3277
3328
  elif name in (b'LM', b'RM', b'PM'): # left / right / paragraph margin
3278
3329
  # Print columns at 10 CPI, matching `.po`; a unit suffix converts. The
3279
3330
  # archive writes both (`.rm 65` and `.rm 6.5"`).
@@ -3637,7 +3688,8 @@ def _hf_within(doc, cur, cur_line):
3637
3688
 
3638
3689
  def _parse_head_foot(cmd: bytes, doc, encoding: str, anchor=None, font_idx=None,
3639
3690
  tab_mark=None, align=None, style_attrs=None,
3640
- pctl_marks=None, within=None):
3691
+ pctl_marks=None, within=None, style_rm_cols=None,
3692
+ lead_48=None):
3641
3693
  """Record `.he`/`.h1`-`.h5` and `.fo`/`.f1`-`.f5` text on the Document.
3642
3694
 
3643
3695
  `.HE` and `.FO` are line 1; the numbered forms select their own line, so a
@@ -3766,6 +3818,18 @@ def _parse_head_foot(cmd: bytes, doc, encoding: str, anchor=None, font_idx=None,
3766
3818
  which_style_attrs = (doc.header_style_attrs if kind == 'H'
3767
3819
  else doc.footer_style_attrs)
3768
3820
  which_style_attrs[line] = style_attrs or frozenset()
3821
+ # M16: see `Document.header_style_rm`/`header_leads` for the probes.
3822
+ which_style_rm = (doc.header_style_rm if kind == 'H'
3823
+ else doc.footer_style_rm)
3824
+ if style_rm_cols is not None:
3825
+ which_style_rm[line] = style_rm_cols
3826
+ else:
3827
+ which_style_rm.pop(line, None)
3828
+ which_leads = doc.header_leads if kind == 'H' else doc.footer_leads
3829
+ if lead_48 is not None:
3830
+ which_leads[line] = lead_48
3831
+ else:
3832
+ which_leads.pop(line, None)
3769
3833
  if parity is not None:
3770
3834
  which_p = doc.headers_parity if kind == 'H' else doc.footers_parity
3771
3835
  which_fonts_p = (doc.header_fonts_parity if kind == 'H'
@@ -5162,6 +5226,26 @@ def _symmetric_blocks(data: bytes, encoding: str, raw_out=None):
5162
5226
  includes, driver[0], sorted(shift_runs), marks, header,
5163
5227
  pcl_programs)
5164
5228
 
5229
+ # The style attributes that KEEP RUNNING past the paragraph that turned them
5230
+ # on, until a later style's own attrs_OFF word clears them (ruling 2026-09-16,
5231
+ # WordStar-Feature-Decision-Register, "Style-library strikeout runs until a
5232
+ # style clears it").
5233
+ #
5234
+ # WSFORMAT.TXT states the inherit rule for the attribute words as a whole --
5235
+ # "if both corresponding bits are off, then the attribute is inherited from
5236
+ # the current state" -- but the only attribute the WS7 LaserJet captures can
5237
+ # actually DEMONSTRATE it for is strikeout: in every corpus document that
5238
+ # turns bold, underline or italic on in a style, the next style used sets
5239
+ # that same bit in its own attrs_off word, so an inheriting model and a
5240
+ # reset-per-paragraph model produce identical pages and the captures cannot
5241
+ # choose between them. Strikeout is the one bit no corpus style ever clears,
5242
+ # which is exactly why its run is visible (NOVEL.WS: 954 overstrike dash rows
5243
+ # across all 52 captured pages). So the rule is applied to strikeout only,
5244
+ # on measured evidence, and the wider question is left open rather than
5245
+ # guessed. Add a bit here only with a capture that shows it.
5246
+ STICKY_STYLE_ATTRS = ((0x01, 'strike'),)
5247
+
5248
+
5165
5249
  def _parse_style_library(raw: bytes, base: int, encoding: str = 'cp437'):
5166
5250
  """The paragraph style library at file-absolute offset `base`.
5167
5251
 
@@ -6146,6 +6230,7 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6146
6230
  hf_align = None
6147
6231
  hf_style_font_idx = None
6148
6232
  hf_style_attrs = None
6233
+ hf_style_rm = None
6149
6234
  if hf_style_w0 is not None and (hf_style_w0 >> 8) == 0x02:
6150
6235
  _hf_entry = style_slots.get(hf_style_w0 & 0xFF)
6151
6236
  if _hf_entry is not None:
@@ -6153,6 +6238,11 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6153
6238
  if _hf_entry.get('font') and any(_hf_entry['font']):
6154
6239
  hf_style_font_idx = _style_font(_hf_entry['font'])
6155
6240
  hf_style_attrs = _hf_entry.get('attrs')
6241
+ # M16: the same 180-HMI-per-column conversion
6242
+ # `emit.py` already applies to a BODY block's own
6243
+ # style right margin, read here for the head.
6244
+ if _hf_entry.get('right_margin_hmi'):
6245
+ hf_style_rm = _hf_entry['right_margin_hmi'] / 180.0
6156
6246
  _parse_head_foot(cmd if strip_hibit else raw.rstrip(), doc,
6157
6247
  encoding,
6158
6248
  anchor=len(doc.blocks) + (1 if cur.lines or
@@ -6175,7 +6265,16 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6175
6265
  else hf_style_font_idx),
6176
6266
  tab_mark=hf_tab_mark,
6177
6267
  align=hf_align, style_attrs=hf_style_attrs,
6178
- pctl_marks=hf_pctl_marks)
6268
+ pctl_marks=hf_pctl_marks,
6269
+ style_rm_cols=hf_style_rm,
6270
+ # M16: the `.lh` in force RIGHT HERE, which is
6271
+ # what the step between running-head lines
6272
+ # turns out to be -- `hf_lead_48` and not
6273
+ # `lead_48`, because a `.lh 0` leaves the
6274
+ # latter standing (it is not a body leading
6275
+ # any renderer could use) while still being
6276
+ # the head's own answer.
6277
+ lead_48=fmt.get('hf_lead_48'))
6179
6278
  # The index of the block this entry POINTS AT -- the one that follows it,
6180
6279
  # which is the block still open (if it has content) or the next to open.
6181
6280
  # "This heading is in the table of contents" refers forward, not back.
@@ -6422,8 +6521,34 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6422
6521
  # value forward, not reset to nothing -- style_fmt.clear()
6423
6522
  # below must not lose it.
6424
6523
  _prev_vmi = style_fmt.get('line_height_vmi')
6524
+ # Ruling 2026-09-16 ("Style-library strikeout runs until a
6525
+ # style clears it"): an attribute a style turns ON stays on
6526
+ # past that paragraph. WSFORMAT.TXT's own rule for the two
6527
+ # attribute words is "if both corresponding bits are off,
6528
+ # then the attribute is inherited from the current state" --
6529
+ # so only a later style that sets the bit in its attrs_OFF
6530
+ # word ends the run. Real WS7 agrees: NOVEL.WS's H3 sets
6531
+ # strikeout on the title page and nothing in that file's
6532
+ # library ever clears it, and the LaserJet capture prints
6533
+ # the overstrike dash row on all 52 pages.
6534
+ _prev_sticky = style_fmt.get('sticky_attrs', frozenset())
6425
6535
  style_fmt.clear()
6426
6536
  style_fmt['style_id'] = slot
6537
+ _sticky = set(_prev_sticky)
6538
+ if entry is not None:
6539
+ _on, _off = entry.get('attrs_on') or 0, entry.get('attrs_off') or 0
6540
+ for _bit, _tag in STICKY_STYLE_ATTRS:
6541
+ if _on & _bit:
6542
+ _sticky.add(_tag)
6543
+ elif _off & _bit:
6544
+ _sticky.discard(_tag)
6545
+ style_fmt['sticky_attrs'] = frozenset(_sticky)
6546
+ if _sticky:
6547
+ # Also for an UNRESOLVABLE handle (a 0x03xx editing-temp
6548
+ # pool entry, or a slot this file's library doesn't
6549
+ # carry): it declares neither word, so every attribute
6550
+ # inherits, including the running one.
6551
+ style_fmt['attrs'] = frozenset(_sticky)
6427
6552
  if entry is not None:
6428
6553
  style_fmt['style_name'] = entry['name']
6429
6554
  style_fmt['heading'] = _style_heading_level(entry['name'])
@@ -6443,8 +6568,11 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6443
6568
  # HMI 1/1800in -> print columns at 10 CPI,
6444
6569
  # the unit .lm/.rm already use (180 = 1 col)
6445
6570
  style_fmt[dst_k] = round(hmi / 180)
6446
- if entry.get('attrs'):
6447
- style_fmt['attrs'] = entry['attrs']
6571
+ if entry.get('attrs') or _sticky:
6572
+ # The style's own ON bits, plus every sticky
6573
+ # attribute still running from an earlier style.
6574
+ style_fmt['attrs'] = frozenset(
6575
+ entry.get('attrs') or ()) | style_fmt['sticky_attrs']
6448
6576
  # Register C5: the style's own declared colour index
6449
6577
  # (0-15, WSFORMAT's fixed CGA/EGA palette -- same
6450
6578
  # space as an inline type-1 colour change). `is not