ctrl-kd 4.7.0__tar.gz → 4.8.0__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 (110) hide show
  1. {ctrl_kd-4.7.0/src/ctrl_kd.egg-info → ctrl_kd-4.8.0}/PKG-INFO +1 -1
  2. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0/src/ctrl_kd.egg-info}/PKG-INFO +1 -1
  3. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrl_kd.egg-info/SOURCES.txt +4 -0
  4. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/__init__.py +1 -1
  5. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/cli.py +10 -7
  6. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/core.py +81 -2
  7. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/emit.py +143 -54
  8. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/layout.py +97 -8
  9. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/pdf.py +1011 -107
  10. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_ctrlkd.py +256 -22
  11. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_graphic_cells.py +2 -2
  12. ctrl_kd-4.8.0/tests/test_head_foot_placement_m16.py +146 -0
  13. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_head_foot_timing.py +91 -0
  14. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_justification.py +1 -1
  15. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_merge_page_number_variable.py +8 -1
  16. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_centring_clip.py +11 -0
  17. ctrl_kd-4.8.0/tests/test_modern_columns.py +379 -0
  18. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_line_spacing.py +25 -2
  19. ctrl_kd-4.8.0/tests/test_modern_page_number.py +344 -0
  20. ctrl_kd-4.8.0/tests/test_modern_sheet_height.py +137 -0
  21. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_verse_defrow.py +32 -4
  22. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_tolerance.py +97 -0
  23. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_v4_column_geometry.py +1 -1
  24. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pn_page_timing.py +158 -0
  25. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_printed_fidelity.py +143 -8
  26. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_rtf_section_spine.py +25 -15
  27. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_structural_checks.py +2 -4
  28. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/LICENSE +0 -0
  29. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/README.md +0 -0
  30. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/pyproject.toml +0 -0
  31. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/setup.cfg +0 -0
  32. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrl_kd.egg-info/dependency_links.txt +0 -0
  33. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrl_kd.egg-info/entry_points.txt +0 -0
  34. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrl_kd.egg-info/top_level.txt +0 -0
  35. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/afm.py +0 -0
  36. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/convert.py +0 -0
  37. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/fontmap.py +0 -0
  38. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/info.py +0 -0
  39. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/pictures.py +0 -0
  40. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/pix.py +0 -0
  41. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/piximg.py +0 -0
  42. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/samples/LYING.WS +0 -0
  43. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/samples/OCAPTAIN.WS +0 -0
  44. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/samples/TWAINLET.WS +0 -0
  45. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/samples/WARPRAYR.WS +0 -0
  46. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/symbolmap.py +0 -0
  47. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/typestyles.py +0 -0
  48. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/writer.py +0 -0
  49. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/src/ctrlkd/wschange.py +0 -0
  50. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_bare_tab_modulus8.py +0 -0
  51. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_dot_comment_prints_nothing.py +0 -0
  52. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_driver_substitutions_exports.py +0 -0
  53. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_endnote_leading_gap.py +0 -0
  54. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_fidelity_gate.py +0 -0
  55. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_flags_toc_inline.py +0 -0
  56. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_form_feed_line_marks.py +0 -0
  57. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_glyph_aspect.py +0 -0
  58. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_graphic_cell_ops.py +0 -0
  59. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_html_print_stylesheet.py +0 -0
  60. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_layout_marks.py +0 -0
  61. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_bullet_glyph.py +0 -0
  62. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_char_substitution.py +0 -0
  63. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_colour_restore.py +0 -0
  64. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_heading_face.py +0 -0
  65. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_hp_patterns.py +0 -0
  66. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_legend_line_spacing.py +0 -0
  67. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_pcl_rectangles.py +0 -0
  68. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_shading_table_rules.py +0 -0
  69. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_lj6dtp_table_rule_weight.py +0 -0
  70. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_load_plugins.py +0 -0
  71. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_mailmerge_data_files.py +0 -0
  72. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_blank_after_tightened_line.py +0 -0
  73. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_box_regions.py +0 -0
  74. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_lint.py +0 -0
  75. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_page_baseline.py +0 -0
  76. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_modern_rtf_structure_rows.py +0 -0
  77. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_note_rulings_20260824.py +0 -0
  78. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_page_parity.py +0 -0
  79. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_paper_verdicts.py +0 -0
  80. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_fidelity.py +0 -0
  81. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_page_membership.py +0 -0
  82. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_page_size_eject.py +0 -0
  83. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_transparent_print.py +0 -0
  84. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_v4_untriaged_fixes.py +0 -0
  85. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pcl_vertical_wraparound.py +0 -0
  86. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_peseta_euro_driver.py +0 -0
  87. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pf_print_reformat.py +0 -0
  88. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pictures.py +0 -0
  89. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_pix.py +0 -0
  90. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_polarity_gate.py +0 -0
  91. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_print_control_labels_invisible.py +0 -0
  92. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_rtf_facing_pages.py +0 -0
  93. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_rtf_head_style_attrs.py +0 -0
  94. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_rtf_keep_with_next.py +0 -0
  95. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_rtf_page_numbers.py +0 -0
  96. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_samples.py +0 -0
  97. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_sawyer_corpus.py +0 -0
  98. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_screenplay_detection.py +0 -0
  99. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_screenplay_pdf.py +0 -0
  100. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_screenplay_rendering.py +0 -0
  101. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_sentence_spacing_n9.py +0 -0
  102. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_style_leading.py +0 -0
  103. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_symbol_span_graphic_chars.py +0 -0
  104. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_trailing_pa_break.py +0 -0
  105. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_verse_quote_couplet.py +0 -0
  106. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_verse_spacing.py +0 -0
  107. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_width_and_cp1252_memo.py +0 -0
  108. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_writer.py +0 -0
  109. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/tests/test_wschange.py +0 -0
  110. {ctrl_kd-4.7.0 → ctrl_kd-4.8.0}/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.8.0
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.8.0
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
@@ -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.8.0'
@@ -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'
@@ -6146,6 +6210,7 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6146
6210
  hf_align = None
6147
6211
  hf_style_font_idx = None
6148
6212
  hf_style_attrs = None
6213
+ hf_style_rm = None
6149
6214
  if hf_style_w0 is not None and (hf_style_w0 >> 8) == 0x02:
6150
6215
  _hf_entry = style_slots.get(hf_style_w0 & 0xFF)
6151
6216
  if _hf_entry is not None:
@@ -6153,6 +6218,11 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6153
6218
  if _hf_entry.get('font') and any(_hf_entry['font']):
6154
6219
  hf_style_font_idx = _style_font(_hf_entry['font'])
6155
6220
  hf_style_attrs = _hf_entry.get('attrs')
6221
+ # M16: the same 180-HMI-per-column conversion
6222
+ # `emit.py` already applies to a BODY block's own
6223
+ # style right margin, read here for the head.
6224
+ if _hf_entry.get('right_margin_hmi'):
6225
+ hf_style_rm = _hf_entry['right_margin_hmi'] / 180.0
6156
6226
  _parse_head_foot(cmd if strip_hibit else raw.rstrip(), doc,
6157
6227
  encoding,
6158
6228
  anchor=len(doc.blocks) + (1 if cur.lines or
@@ -6175,7 +6245,16 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6175
6245
  else hf_style_font_idx),
6176
6246
  tab_mark=hf_tab_mark,
6177
6247
  align=hf_align, style_attrs=hf_style_attrs,
6178
- pctl_marks=hf_pctl_marks)
6248
+ pctl_marks=hf_pctl_marks,
6249
+ style_rm_cols=hf_style_rm,
6250
+ # M16: the `.lh` in force RIGHT HERE, which is
6251
+ # what the step between running-head lines
6252
+ # turns out to be -- `hf_lead_48` and not
6253
+ # `lead_48`, because a `.lh 0` leaves the
6254
+ # latter standing (it is not a body leading
6255
+ # any renderer could use) while still being
6256
+ # the head's own answer.
6257
+ lead_48=fmt.get('hf_lead_48'))
6179
6258
  # The index of the block this entry POINTS AT -- the one that follows it,
6180
6259
  # which is the block still open (if it has content) or the next to open.
6181
6260
  # "This heading is in the table of contents" refers forward, not back.
@@ -2883,6 +2883,30 @@ def hf_runs(txt):
2883
2883
  return runs
2884
2884
 
2885
2885
 
2886
+ def _rtf_footer_in_use(doc, slots=None):
2887
+ """Is a FOOTER declared at all -- with text, bare, or carrying only
2888
+ invisible characters?
2889
+
2890
+ The question WordStar's automatic page number turns on ("active only
2891
+ when the footers are not in use", WSFORMAT.WS), and deliberately NOT
2892
+ the same question as "is there footer text to draw". `_hf_slots` is
2893
+ the drawing answer and drops an event with no text; this is the
2894
+ declaration answer and counts it, matching `pdf._close_page`'s own
2895
+ `footer_in_use = bool(page_ftrs)` taken before the empty slots are
2896
+ dropped.
2897
+
2898
+ `slots` is `_rtf_running_heads`' own argument: None for the document
2899
+ (section 1, which is the whole document for every file that opens no
2900
+ later section), or a resolved `(hdr_slots, ftr_slots)` pair for a
2901
+ later section -- in which case that section's own already-resolved
2902
+ footer slots are the answer, because `_rtf_hf_slots_at` is what
2903
+ decided them."""
2904
+ if slots is not None:
2905
+ return bool(slots[1])
2906
+ return any(kind == 'F' for kind, _l, _t, _a
2907
+ in getattr(doc, 'hf_events', ()))
2908
+
2909
+
2886
2910
  def _rtf_auto_page_number(doc, page_numbers='auto'):
2887
2911
  """Whether WordStar's own AUTOMATIC page number -- the one `.pc`
2888
2912
  positions, never a `#` the author typed into a real `.he`/`.fo` -- is
@@ -3073,7 +3097,7 @@ def _rtf_keep_plan(doc):
3073
3097
  return plan
3074
3098
 
3075
3099
 
3076
- def _rtf_section_breaks(doc, printed):
3100
+ def _rtf_section_breaks(doc):
3077
3101
  r"""`{block_index: (cols, gutter, hdr_slots, ftr_slots)}` -- THE SECTION
3078
3102
  SPINE (planning #264 R1, ruled 2026-09-14; packet section 3's
3079
3103
  recommendation and rows A7/A9/A13).
@@ -3087,13 +3111,16 @@ def _rtf_section_breaks(doc, printed):
3087
3111
 
3088
3112
  TWO things open one:
3089
3113
 
3090
- A7 a change of newspaper-column regime (`.co n, gutter`). PRINTED
3091
- ONLY. Modern PDF has no column model at all, and the 2026-08-05
3092
- ruling is that "Modern PDF needs to be the printed version of
3093
- the Modern RTF" -- so a columnar Modern RTF would be a Modern
3094
- RTF its own PDF could not render. Modern stays one column by
3095
- design. (Modern HTML's `column-count` is a separate, older
3096
- surface and is untouched.)
3114
+ A7 a change of newspaper-column regime (`.co n, gutter`). BOTH
3115
+ MODES since M17b (2026-09-15). R1 made this Printed-only on the
3116
+ stated grounds that "Modern PDF has no column model at all, and
3117
+ the 2026-08-05 ruling is that Modern PDF needs to be the printed
3118
+ version of the Modern RTF -- so a columnar Modern RTF would be a
3119
+ Modern RTF its own PDF could not render". Modern PDF has a
3120
+ column model now (M17, `pdf._modern_column_width`), so the same
3121
+ ruling read the same way now says the opposite: a ONE-column
3122
+ Modern RTF is the one its own PDF cannot render. (Modern HTML's
3123
+ `column-count` is a separate, older surface and is untouched.)
3097
3124
  A13 a running head or foot REDEFINED mid-document -- a second
3098
3125
  definition of the same slot with different text. The new section
3099
3126
  carries its own `\header`/`\footer` groups.
@@ -3105,18 +3132,17 @@ def _rtf_section_breaks(doc, printed):
3105
3132
  re-anchor (`\pgnrestart\pgnstarts`), and a mid-document `.po`/margin
3106
3133
  change. R1 names A7, A9 and A13; those are the three built."""
3107
3134
  anchors = set(_rtf_hf_redefinitions(doc))
3108
- state = _rtf_columns_state(doc) if printed else None
3109
- if printed:
3110
- for bi, b in enumerate(doc.blocks):
3111
- # Only a REAL block opens a column regime: a sentinel inherits
3112
- # the state around it and must not read as a change.
3113
- if b.columns is None or bi == 0:
3114
- continue
3115
- if state[bi] != state[bi - 1]:
3116
- anchors.add(bi)
3135
+ state = _rtf_columns_state(doc)
3136
+ for bi, b in enumerate(doc.blocks):
3137
+ # Only a REAL block opens a column regime: a sentinel inherits
3138
+ # the state around it and must not read as a change.
3139
+ if b.columns is None or bi == 0:
3140
+ continue
3141
+ if state[bi] != state[bi - 1]:
3142
+ anchors.add(bi)
3117
3143
  out = {}
3118
3144
  for bi in sorted(a for a in anchors if 0 < a < len(doc.blocks)):
3119
- cols, gutter = state[bi] if printed else (1, None)
3145
+ cols, gutter = state[bi]
3120
3146
  out[bi] = (cols, gutter,
3121
3147
  _rtf_hf_slots_at(doc, 'H', bi),
3122
3148
  _rtf_hf_slots_at(doc, 'F', bi))
@@ -3233,11 +3259,17 @@ def _rtf_running_heads(doc, headers=True, auto_page_number=False,
3233
3259
  A4 the head's own alignment (`header_align`/`footer_align`, the
3234
3260
  `.h#`/`.f#` argument's embedded style-sheet reference, planning
3235
3261
  #255) becomes `\\qr`/`\\qc` on the group's paragraph. RTF's
3236
- header is ONE paragraph carrying every declared line, so the
3237
- alignment applied is the FIRST rendered line's -- no corpus
3238
- document declares two head lines with different alignments, and
3239
- splitting the group into a paragraph per line would change the
3240
- bytes of every multi-line head to buy that case.
3262
+ header is ONE paragraph carrying every declared line, joined by
3263
+ `\\line`, for as long as those lines AGREE on their alignment --
3264
+ which is every head and foot in the corpus but one. This row
3265
+ originally applied the FIRST line's alignment to the whole
3266
+ group, on the stated grounds that no corpus document declares
3267
+ two head lines with different alignments; `sawyer/REF/
3268
+ BOOKLET.WS` does, and its left-aligned second head line was
3269
+ right-aligned with its first. A group whose lines disagree is
3270
+ written as a paragraph PER LINE instead (2026-09-15), since
3271
+ `\\line` is a break inside a paragraph and cannot carry a second
3272
+ alignment. Every other document's bytes are unchanged.
3241
3273
  A5 `\\headery`/`\\footery` -- see `_rtf_head_foot_distance`.
3242
3274
 
3243
3275
  Planning #264 item 5: the head's OWN print attributes
@@ -3273,13 +3305,28 @@ def _rtf_running_heads(doc, headers=True, auto_page_number=False,
3273
3305
  # and WordStar's automatic number stays off -- which is exactly what
3274
3306
  # `pdf._close_page` records (`footer_in_use = bool(page_ftrs)`, before
3275
3307
  # the empty slots are dropped).
3308
+ #
3309
+ # M15 FOLLOW-UP (2026-09-15): that is what the paragraph above says,
3310
+ # and `ftr_slots` was not it. `_hf_slots` drops an event with NO text
3311
+ # at all (`if ... not txt: continue`), so a document whose only footer
3312
+ # command is a BARE `.fo` read as "no footer in use" and RTF printed
3313
+ # the automatic number on it -- while both PDFs, reading
3314
+ # `footer_in_use` off the un-dropped slots, printed none. Real WS7
3315
+ # prints none: "That holds whether the footer has text, is bare, or
3316
+ # contains only invisible characters" (research/2026-09-15_ws7-
3317
+ # missing-auto-page-number.md, rule 2; the same rule the PDF's own
3318
+ # triage cause 1 fixed on 2026-09-12). Five documents in the public
3319
+ # archive are that shape -- MACROS/HOLYMAC/4MAC2, 4MAC3, 7MAC2, 7MAC3
3320
+ # and LSRBOX/LSRBOX.WS, four of them named in that research as WS7
3321
+ # printing no number on any of their 41/53/35/35 pages -- and their
3322
+ # `rtf.printed` and `rtf.modern` both move here.
3276
3323
  # planning #264 R7 (ruled 2026-09-14): the two flags are SEPARATE and
3277
3324
  # RTF follows the PDF. `headers` governs running heads and feet -- and
3278
3325
  # so a `#` the author typed INSIDE one, which is that head's own text
3279
3326
  # -- while `--page-numbers` governs WordStar's AUTOMATIC number alone.
3280
3327
  # The first RTF batch made `--headers off` swallow the automatic number
3281
3328
  # too; that is reverted here, on both surfaces.
3282
- show_auto_num = auto_page_number and not ftr_slots
3329
+ show_auto_num = auto_page_number and not _rtf_footer_in_use(doc, slots)
3283
3330
  if not headers:
3284
3331
  hdr_slots, ftr_slots = {}, {}
3285
3332
  headery, footery = _rtf_head_foot_distance(doc.meta.get('page') or {},
@@ -3299,7 +3346,7 @@ def _rtf_running_heads(doc, headers=True, auto_page_number=False,
3299
3346
  def group(name, lines, faces, aligns, attrs):
3300
3347
  if not lines:
3301
3348
  return ''
3302
- rendered, align_ctl = [], ''
3349
+ rendered, line_aligns = [], []
3303
3350
  for n in sorted(lines):
3304
3351
  txt = lines[n]
3305
3352
  if subst is not None:
@@ -3309,8 +3356,7 @@ def _rtf_running_heads(doc, headers=True, auto_page_number=False,
3309
3356
  runs = hf_runs(txt)
3310
3357
  if not runs:
3311
3358
  continue # control-bytes-only head
3312
- if not rendered:
3313
- align_ctl = _RTF_HF_ALIGN.get(aligns.get(n), '')
3359
+ line_aligns.append(_RTF_HF_ALIGN.get(aligns.get(n), ''))
3314
3360
  # planning #264 item 5 (planning #255's `header_style_attrs`):
3315
3361
  # the print attributes the head's OWN style turns on. A
3316
3362
  # `.h#`/`.f#` argument can name a style-sheet entry, and that
@@ -3330,8 +3376,26 @@ def _rtf_running_heads(doc, headers=True, auto_page_number=False,
3330
3376
  for text, styles in runs))
3331
3377
  if not rendered:
3332
3378
  return ''
3333
- return (r'{\%s \pard\plain %s\f0\fs22 %s\par}'
3334
- % (name, align_ctl, r'\line '.join(rendered)))
3379
+ # ONE PARAGRAPH while every rendered line agrees on its alignment
3380
+ # -- which is every head and foot in the corpus but one, so this
3381
+ # is byte-identical to the single-paragraph group RTF has always
3382
+ # written. Planning #264 item 4 (row A4) applied the FIRST line's
3383
+ # alignment to the whole group on the stated grounds that "no
3384
+ # corpus document declares two head lines with different
3385
+ # alignments"; `sawyer/REF/BOOKLET.WS` does (a right-aligned
3386
+ # "Header Odd" over a left-aligned "Header Even"), and the group
3387
+ # right-aligned both. `\line` is a break INSIDE a paragraph and
3388
+ # cannot carry a second alignment, so the lines that disagree get
3389
+ # a paragraph each -- the only form RTF has for this -- and the
3390
+ # reader lays them out exactly as Modern PDF now draws them
3391
+ # (2026-08-05: Modern PDF is the printed form of the Modern RTF).
3392
+ if len(set(line_aligns)) <= 1:
3393
+ return (r'{\%s \pard\plain %s\f0\fs22 %s\par}'
3394
+ % (name, line_aligns[0], r'\line '.join(rendered)))
3395
+ return (r'{\%s %s}'
3396
+ % (name, ''.join(r'\pard\plain %s\f0\fs22 %s\par'
3397
+ % (a, r) for a, r in zip(line_aligns,
3398
+ rendered))))
3335
3399
 
3336
3400
  def sided(which, name, slots):
3337
3401
  """One head/foot family, as `\\headerl`/`\\headerr` when the
@@ -3950,8 +4014,8 @@ def emit_rtf(doc, mode='printed', notes=DEFAULT_NOTE_KINDS, styles=True,
3950
4014
  # `_rtf_section_breaks` for what opens a section and what deliberately
3951
4015
  # does not. A document whose geometry never changes gets an empty dict
3952
4016
  # here and emits exactly the bytes it always did.
3953
- section_breaks = _rtf_section_breaks(doc, printed)
3954
- columns_state = _rtf_columns_state(doc) if printed else None
4017
+ section_breaks = _rtf_section_breaks(doc)
4018
+ columns_state = _rtf_columns_state(doc)
3955
4019
  # planning #264 R2 (packet row A8): `.cp n`/`.cc n` -> `\keep`/`\keepn`
3956
4020
  # on the paragraphs they asked to hold together. See `_rtf_keep_plan`.
3957
4021
  keep_plan = _rtf_keep_plan(doc)
@@ -3983,14 +4047,12 @@ def emit_rtf(doc, mode='printed', notes=DEFAULT_NOTE_KINDS, styles=True,
3983
4047
  quote_fi_cols = None
3984
4048
  if b.kind == 'colbreak':
3985
4049
  # planning #264 R1 (packet row A9): `.cb` breaks to the next
3986
- # column -- the reader's own `\column`. PRINTED ONLY, and
3987
- # inside a columnar region only: Modern has no columns
3988
- # (`_rtf_section_breaks`) and Modern's own flow drops `.cb`
3989
- # entirely (`layout.semantic_flow` makes a break item for
3990
- # `pagebreak` alone), so a `\column` there would be a page
3991
- # break Modern PDF does not take. Outside a region the control
3992
- # would mean the same thing to a reader, which is not what
3993
- # WordStar did with it either -- nothing is written.
4050
+ # column -- the reader's own `\column`. Inside a columnar
4051
+ # region only; outside one the control would mean a break a
4052
+ # reader could still take, which is not what WordStar did with
4053
+ # it either, so nothing is written. BOTH MODES since M17b:
4054
+ # Modern PDF's own column cursor takes `.cb` to the next
4055
+ # column (`pdf._modern_streams`), so its RTF says the same.
3994
4056
  quote_open = False
3995
4057
  quote_fi_cols = None
3996
4058
  if columns_state is not None and columns_state[bi][0] > 1:
@@ -4007,9 +4069,8 @@ def emit_rtf(doc, mode='printed', notes=DEFAULT_NOTE_KINDS, styles=True,
4007
4069
  # WINGDING.CHT's own WS7 capture (its author's `.pa` markers are
4008
4070
  # a manual column-simulation convention that predates the real
4009
4071
  # `.co5` governing the same content; honouring them fragments
4010
- # one real column into two short pages). RTF had no columns to
4011
- # fragment before this commit, which is why it could keep the
4012
- # break; now it has, so it reads the same fact the same way.
4072
+ # one real column into two short pages). Modern PDF absorbs it
4073
+ # on the same evidence since M17, so Modern RTF does too.
4013
4074
  if columns_state is not None and columns_state[bi][0] > 1:
4014
4075
  continue
4015
4076
  parts.append(r'\page ')
@@ -4283,10 +4344,15 @@ def emit_rtf(doc, mode='printed', notes=DEFAULT_NOTE_KINDS, styles=True,
4283
4344
  # derived margins are left exactly as declared (still top/bottom/left
4284
4345
  # relative to the text, same as WordStar's own driver-level rotation
4285
4346
  # never re-interpreted them either) -- only the CANVAS they sit against
4286
- # changes shape. Printed only: Modern's page is its own fixed Letter
4287
- # regardless of the document's declared orientation (same doctrine as
4288
- # every other Printed-only geometry item).
4289
- landscape = printed and doc.meta.get('formatting', {}).get('orientation') == 'landscape'
4347
+ # changes shape. BOTH MODES since M17b (2026-09-15): this used to be
4348
+ # Printed-only, on the reading that "Modern's page is its own fixed
4349
+ # Letter". M17 retired that reading for Modern PDF -- a landscape
4350
+ # document's Modern MediaBox is 792x612 now -- and Modern PDF is the
4351
+ # printed form of THIS file (ruled 2026-08-05), so a square 8.5x8.5
4352
+ # Modern RTF would be an RTF its own PDF does not print. It is also
4353
+ # the paged-surface doctrine's point 2 read literally: "honor `.pr
4354
+ # or=l` in ALL paged surfaces", and Modern RTF is one.
4355
+ landscape = doc.meta.get('formatting', {}).get('orientation') == 'landscape'
4290
4356
  if landscape:
4291
4357
  # planning #256 (sawyer/REF/-HOW-TO.RJS + the HP-ENV.LST/HP-ENVMM.LST
4292
4358
  # mailing-label pair): a plain swap of `page['height_in']`/`pw_in`
@@ -4312,11 +4378,30 @@ def emit_rtf(doc, mode='printed', notes=DEFAULT_NOTE_KINDS, styles=True,
4312
4378
  def _twips_lines(key, default_lines):
4313
4379
  v = page.get(key, default_lines)
4314
4380
  return int(round(float(v) * 240)) # 1 line at 6 LPI = 240 twips
4381
+
4382
+ def _paper_twips(height_in):
4383
+ """A declared sheet height in inches -> `\\paperh` twips, with `.pl 0`
4384
+ falling back to Letter.
4385
+
4386
+ `.pl 0` IS NOT A SHEET. It is WordStar's "page breaks off" (bug
4387
+ 12284, `core._text_lines_per_page`), and this emitter's text model
4388
+ already never breaks, so the PAGE BOX falls back to Letter -- the
4389
+ rule Printed PDF has carried since `_resolved_page_height` was
4390
+ written and Modern PDF adopted in M18 (`pdf._modern_sheet_h`),
4391
+ quoted here rather than re-decided.
4392
+
4393
+ RTF was the surface still writing the arithmetic straight through:
4394
+ `\\paperh0`, which LibreOffice refuses to open at all. Five
4395
+ documents: `LSRBOX/LSRBOXES.MRG`, `LSRBOX/LSRLINES.MRG`,
4396
+ `RTF-RJS/1-5LINES.WS`, `RTF-RJS/1-SINGLE.WS`, `RTF-RJS/2-DOUBLE.WS`.
4397
+ A document that declares any real height is untouched."""
4398
+ twips = int(round(float(height_in) * 1440))
4399
+ return twips if twips > 0 else 15840
4315
4400
  if printed:
4316
4401
  margt = _twips_lines('mt_lines', 3.0)
4317
4402
  margb = _twips_lines('mb_lines', 8.0)
4318
4403
  margl = int(round(float(page.get('po_cols', 8.0)) * 144))
4319
- paperh = int(round(float(page.get('height_in', 11.0)) * 1440))
4404
+ paperh = _paper_twips(page.get('height_in', 11.0))
4320
4405
  else:
4321
4406
  margt = (_twips_lines('mt_lines', 6.0)
4322
4407
  if page.get('mt_source', 'default') != 'default' else 1440)
@@ -4324,8 +4409,14 @@ def emit_rtf(doc, mode='printed', notes=DEFAULT_NOTE_KINDS, styles=True,
4324
4409
  if page.get('mb_source', 'default') != 'default' else 1440)
4325
4410
  margl = (int(round(float(page.get('po_cols', 10.0)) * 144))
4326
4411
  if page.get('po_source', 'default') != 'default' else 1440)
4327
- paperh = (int(round(float(page.get('height_in', 11.0)) * 1440))
4328
- if page.get('size_source', 'default') != 'default'
4412
+ # A LANDSCAPE sheet is a declared sheet whatever `.pl` says, so it
4413
+ # is never the 11in default: `page` above is already the swapped,
4414
+ # orientation-aware pair (a `.pl 8.5"` resolves to 8.5 tall x 11
4415
+ # wide, not to the un-landscape-aware 8.5x8.5 SQUARE the portrait
4416
+ # resolution gives). Modern PDF composes on exactly that height
4417
+ # (`pdf._modern_sheet_h`), which is what makes the two agree.
4418
+ paperh = (_paper_twips(page.get('height_in', 11.0))
4419
+ if landscape or page.get('size_source', 'default') != 'default'
4329
4420
  else 15840)
4330
4421
  # width joined the page model 2026-08-06: A4-tall documents get the
4331
4422
  # 210mm sheet; everything else (and every default) stays 12240 twips
@@ -4390,11 +4481,9 @@ def emit_rtf(doc, mode='printed', notes=DEFAULT_NOTE_KINDS, styles=True,
4390
4481
  # regime. `\cols` is a section property and the page setup is section
4391
4482
  # 1's; a document that opens outside a columnar region (all but a
4392
4483
  # handful) resolves to one column and writes nothing, so its bytes do
4393
- # not move. Printed only -- see `_rtf_section_breaks` for why Modern
4394
- # stays single-column.
4395
- if printed:
4396
- pagesetup += _rtf_cols_control(*(_rtf_columns_state(doc)[0]
4397
- if doc.blocks else (1, None)))
4484
+ # not move. BOTH MODES since M17b -- see `_rtf_section_breaks`.
4485
+ pagesetup += _rtf_cols_control(*(_rtf_columns_state(doc)[0]
4486
+ if doc.blocks else (1, None)))
4398
4487
  # round 18 (RULINGS-LEDGER row 4): TOC/Index at the document's own end,
4399
4488
  # gated by `--toc` (default off, the ruled default).
4400
4489
  toc_index = _rtf_toc_index(doc, printed) if toc else ''