ctrl-kd 4.8.0__tar.gz → 4.9.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 (114) hide show
  1. {ctrl_kd-4.8.0/src/ctrl_kd.egg-info → ctrl_kd-4.9.0}/PKG-INFO +28 -1
  2. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/README.md +27 -0
  3. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0/src/ctrl_kd.egg-info}/PKG-INFO +28 -1
  4. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrl_kd.egg-info/SOURCES.txt +4 -0
  5. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/__init__.py +4 -1
  6. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/cli.py +69 -1
  7. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/core.py +237 -92
  8. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/emit.py +46 -0
  9. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/layout.py +55 -8
  10. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/pdf.py +129 -33
  11. ctrl_kd-4.9.0/src/ctrlkd/quirks.py +461 -0
  12. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/symbolmap.py +40 -3
  13. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_ctrlkd.py +3 -3
  14. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_graphic_cells.py +2 -2
  15. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_justification.py +1 -1
  16. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_v4_column_geometry.py +1 -1
  17. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_printed_fidelity.py +1 -1
  18. ctrl_kd-4.9.0/tests/test_quirks.py +575 -0
  19. ctrl_kd-4.9.0/tests/test_style_strikeout_runs_until_cleared.py +297 -0
  20. ctrl_kd-4.9.0/tests/test_style_symbol_map_resolved_face.py +173 -0
  21. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_symbol_span_graphic_chars.py +30 -12
  22. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/LICENSE +0 -0
  23. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/pyproject.toml +0 -0
  24. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/setup.cfg +0 -0
  25. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrl_kd.egg-info/dependency_links.txt +0 -0
  26. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrl_kd.egg-info/entry_points.txt +0 -0
  27. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrl_kd.egg-info/top_level.txt +0 -0
  28. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/afm.py +0 -0
  29. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/convert.py +0 -0
  30. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/fontmap.py +0 -0
  31. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/info.py +0 -0
  32. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/pictures.py +0 -0
  33. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/pix.py +0 -0
  34. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/piximg.py +0 -0
  35. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/samples/LYING.WS +0 -0
  36. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/samples/OCAPTAIN.WS +0 -0
  37. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/samples/TWAINLET.WS +0 -0
  38. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/samples/WARPRAYR.WS +0 -0
  39. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/typestyles.py +0 -0
  40. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/writer.py +0 -0
  41. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/src/ctrlkd/wschange.py +0 -0
  42. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_bare_tab_modulus8.py +0 -0
  43. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_dot_comment_prints_nothing.py +0 -0
  44. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_driver_substitutions_exports.py +0 -0
  45. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_endnote_leading_gap.py +0 -0
  46. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_fidelity_gate.py +0 -0
  47. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_flags_toc_inline.py +0 -0
  48. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_form_feed_line_marks.py +0 -0
  49. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_glyph_aspect.py +0 -0
  50. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_graphic_cell_ops.py +0 -0
  51. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_head_foot_placement_m16.py +0 -0
  52. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_head_foot_timing.py +0 -0
  53. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_html_print_stylesheet.py +0 -0
  54. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_layout_marks.py +0 -0
  55. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_bullet_glyph.py +0 -0
  56. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_char_substitution.py +0 -0
  57. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_colour_restore.py +0 -0
  58. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_heading_face.py +0 -0
  59. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_hp_patterns.py +0 -0
  60. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_legend_line_spacing.py +0 -0
  61. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_pcl_rectangles.py +0 -0
  62. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_shading_table_rules.py +0 -0
  63. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_lj6dtp_table_rule_weight.py +0 -0
  64. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_load_plugins.py +0 -0
  65. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_mailmerge_data_files.py +0 -0
  66. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_merge_page_number_variable.py +0 -0
  67. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_blank_after_tightened_line.py +0 -0
  68. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_box_regions.py +0 -0
  69. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_centring_clip.py +0 -0
  70. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_columns.py +0 -0
  71. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_line_spacing.py +0 -0
  72. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_lint.py +0 -0
  73. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_page_baseline.py +0 -0
  74. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_page_number.py +0 -0
  75. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_rtf_structure_rows.py +0 -0
  76. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_sheet_height.py +0 -0
  77. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_modern_verse_defrow.py +0 -0
  78. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_note_rulings_20260824.py +0 -0
  79. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_page_parity.py +0 -0
  80. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_paper_verdicts.py +0 -0
  81. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_fidelity.py +0 -0
  82. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_page_membership.py +0 -0
  83. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_page_size_eject.py +0 -0
  84. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_tolerance.py +0 -0
  85. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_transparent_print.py +0 -0
  86. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_v4_untriaged_fixes.py +0 -0
  87. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pcl_vertical_wraparound.py +0 -0
  88. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_peseta_euro_driver.py +0 -0
  89. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pf_print_reformat.py +0 -0
  90. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pictures.py +0 -0
  91. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pix.py +0 -0
  92. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_pn_page_timing.py +0 -0
  93. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_polarity_gate.py +0 -0
  94. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_print_control_labels_invisible.py +0 -0
  95. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_rtf_facing_pages.py +0 -0
  96. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_rtf_head_style_attrs.py +0 -0
  97. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_rtf_keep_with_next.py +0 -0
  98. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_rtf_page_numbers.py +0 -0
  99. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_rtf_section_spine.py +0 -0
  100. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_samples.py +0 -0
  101. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_sawyer_corpus.py +0 -0
  102. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_screenplay_detection.py +0 -0
  103. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_screenplay_pdf.py +0 -0
  104. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_screenplay_rendering.py +0 -0
  105. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_sentence_spacing_n9.py +0 -0
  106. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_structural_checks.py +0 -0
  107. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_style_leading.py +0 -0
  108. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_trailing_pa_break.py +0 -0
  109. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_verse_quote_couplet.py +0 -0
  110. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_verse_spacing.py +0 -0
  111. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_width_and_cp1252_memo.py +0 -0
  112. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_writer.py +0 -0
  113. {ctrl_kd-4.8.0 → ctrl_kd-4.9.0}/tests/test_wschange.py +0 -0
  114. {ctrl_kd-4.8.0 → ctrl_kd-4.9.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.8.0
3
+ Version: 4.9.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
@@ -46,6 +46,8 @@ $ ctrl-kd --diagnose MYSTERY.FIL # what IS this file?
46
46
  $ ctrl-kd --comments MEMO.WS # include the author's hidden comments
47
47
  $ ctrl-kd --no-notes PAPER.WS # body text only, no notes
48
48
  $ ctrl-kd --samples DIR # write 4 bundled public-domain sample .WS files into DIR
49
+ $ ctrl-kd --list-quirks ESSAY.WS # which known quirks this file has, and why
50
+ $ ctrl-kd --quirk stray-style-strikeout NOVEL.WS # turn one on
49
51
  ```
50
52
 
51
53
  ## Modes
@@ -70,6 +72,31 @@ Download Windows x86_64: [Latest Version](https://github.com/jonmichaels/ctrl-kd
70
72
 
71
73
  Python ≥ 3.9, no dependencies. Library API: `ctrlkd.convert(data, to='html')`.
72
74
 
75
+ ## Quirks
76
+
77
+ Some WordStar files ask for something the bytes do not quite mean. A file's own
78
+ header names the printer it was last printed through, and some of those
79
+ printers were modified so that a character prints as something else — a peseta
80
+ printing as a euro sign, an underscore printing as an em dash. Other files
81
+ carry a setting somebody left switched on by accident, like a paragraph style
82
+ that crosses out every line it touches.
83
+
84
+ ctrl-kd is faithful by default and never quietly cleans anything up. Instead
85
+ each such departure is a named **quirk** you can see and switch:
86
+
87
+ ```console
88
+ $ ctrl-kd --list-quirks # every quirk, with a plain-language description
89
+ $ ctrl-kd --list-quirks NOVEL.WS # which of them apply to this file, and why
90
+ $ ctrl-kd --quirk NAME FILE # turn one on (repeatable)
91
+ $ ctrl-kd --no-quirk NAME FILE # turn one off (repeatable)
92
+ $ ctrl-kd --quirks off FILE # none at all: the most literal reading
93
+ ```
94
+
95
+ Quirks whose evidence is in the file itself (its own printer header) are on by
96
+ default; ones that are somebody's judgement rather than the file's own evidence
97
+ are off until you ask. Writing your own — including as a pip-installable
98
+ plugin — is in **[EXTENDING.md](EXTENDING.md)**.
99
+
73
100
  ## Adding an output format
74
101
 
75
102
  An output format is one function over the parsed document — register it with the
@@ -27,6 +27,8 @@ $ ctrl-kd --diagnose MYSTERY.FIL # what IS this file?
27
27
  $ ctrl-kd --comments MEMO.WS # include the author's hidden comments
28
28
  $ ctrl-kd --no-notes PAPER.WS # body text only, no notes
29
29
  $ ctrl-kd --samples DIR # write 4 bundled public-domain sample .WS files into DIR
30
+ $ ctrl-kd --list-quirks ESSAY.WS # which known quirks this file has, and why
31
+ $ ctrl-kd --quirk stray-style-strikeout NOVEL.WS # turn one on
30
32
  ```
31
33
 
32
34
  ## Modes
@@ -51,6 +53,31 @@ Download Windows x86_64: [Latest Version](https://github.com/jonmichaels/ctrl-kd
51
53
 
52
54
  Python ≥ 3.9, no dependencies. Library API: `ctrlkd.convert(data, to='html')`.
53
55
 
56
+ ## Quirks
57
+
58
+ Some WordStar files ask for something the bytes do not quite mean. A file's own
59
+ header names the printer it was last printed through, and some of those
60
+ printers were modified so that a character prints as something else — a peseta
61
+ printing as a euro sign, an underscore printing as an em dash. Other files
62
+ carry a setting somebody left switched on by accident, like a paragraph style
63
+ that crosses out every line it touches.
64
+
65
+ ctrl-kd is faithful by default and never quietly cleans anything up. Instead
66
+ each such departure is a named **quirk** you can see and switch:
67
+
68
+ ```console
69
+ $ ctrl-kd --list-quirks # every quirk, with a plain-language description
70
+ $ ctrl-kd --list-quirks NOVEL.WS # which of them apply to this file, and why
71
+ $ ctrl-kd --quirk NAME FILE # turn one on (repeatable)
72
+ $ ctrl-kd --no-quirk NAME FILE # turn one off (repeatable)
73
+ $ ctrl-kd --quirks off FILE # none at all: the most literal reading
74
+ ```
75
+
76
+ Quirks whose evidence is in the file itself (its own printer header) are on by
77
+ default; ones that are somebody's judgement rather than the file's own evidence
78
+ are off until you ask. Writing your own — including as a pip-installable
79
+ plugin — is in **[EXTENDING.md](EXTENDING.md)**.
80
+
54
81
  ## Adding an output format
55
82
 
56
83
  An output format is one function over the parsed document — register it with the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrl-kd
3
- Version: 4.8.0
3
+ Version: 4.9.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
@@ -46,6 +46,8 @@ $ ctrl-kd --diagnose MYSTERY.FIL # what IS this file?
46
46
  $ ctrl-kd --comments MEMO.WS # include the author's hidden comments
47
47
  $ ctrl-kd --no-notes PAPER.WS # body text only, no notes
48
48
  $ ctrl-kd --samples DIR # write 4 bundled public-domain sample .WS files into DIR
49
+ $ ctrl-kd --list-quirks ESSAY.WS # which known quirks this file has, and why
50
+ $ ctrl-kd --quirk stray-style-strikeout NOVEL.WS # turn one on
49
51
  ```
50
52
 
51
53
  ## Modes
@@ -70,6 +72,31 @@ Download Windows x86_64: [Latest Version](https://github.com/jonmichaels/ctrl-kd
70
72
 
71
73
  Python ≥ 3.9, no dependencies. Library API: `ctrlkd.convert(data, to='html')`.
72
74
 
75
+ ## Quirks
76
+
77
+ Some WordStar files ask for something the bytes do not quite mean. A file's own
78
+ header names the printer it was last printed through, and some of those
79
+ printers were modified so that a character prints as something else — a peseta
80
+ printing as a euro sign, an underscore printing as an em dash. Other files
81
+ carry a setting somebody left switched on by accident, like a paragraph style
82
+ that crosses out every line it touches.
83
+
84
+ ctrl-kd is faithful by default and never quietly cleans anything up. Instead
85
+ each such departure is a named **quirk** you can see and switch:
86
+
87
+ ```console
88
+ $ ctrl-kd --list-quirks # every quirk, with a plain-language description
89
+ $ ctrl-kd --list-quirks NOVEL.WS # which of them apply to this file, and why
90
+ $ ctrl-kd --quirk NAME FILE # turn one on (repeatable)
91
+ $ ctrl-kd --no-quirk NAME FILE # turn one off (repeatable)
92
+ $ ctrl-kd --quirks off FILE # none at all: the most literal reading
93
+ ```
94
+
95
+ Quirks whose evidence is in the file itself (its own printer header) are on by
96
+ default; ones that are somebody's judgement rather than the file's own evidence
97
+ are off until you ask. Writing your own — including as a pip-installable
98
+ plugin — is in **[EXTENDING.md](EXTENDING.md)**.
99
+
73
100
  ## Adding an output format
74
101
 
75
102
  An output format is one function over the parsed document — register it with the
@@ -19,6 +19,7 @@ src/ctrlkd/pdf.py
19
19
  src/ctrlkd/pictures.py
20
20
  src/ctrlkd/pix.py
21
21
  src/ctrlkd/piximg.py
22
+ src/ctrlkd/quirks.py
22
23
  src/ctrlkd/symbolmap.py
23
24
  src/ctrlkd/typestyles.py
24
25
  src/ctrlkd/writer.py
@@ -85,6 +86,7 @@ tests/test_pn_page_timing.py
85
86
  tests/test_polarity_gate.py
86
87
  tests/test_print_control_labels_invisible.py
87
88
  tests/test_printed_fidelity.py
89
+ tests/test_quirks.py
88
90
  tests/test_rtf_facing_pages.py
89
91
  tests/test_rtf_head_style_attrs.py
90
92
  tests/test_rtf_keep_with_next.py
@@ -98,6 +100,8 @@ tests/test_screenplay_rendering.py
98
100
  tests/test_sentence_spacing_n9.py
99
101
  tests/test_structural_checks.py
100
102
  tests/test_style_leading.py
103
+ tests/test_style_strikeout_runs_until_cleared.py
104
+ tests/test_style_symbol_map_resolved_face.py
101
105
  tests/test_symbol_span_graphic_chars.py
102
106
  tests/test_trailing_pa_break.py
103
107
  tests/test_verse_quote_couplet.py
@@ -4,8 +4,11 @@ from .core import (detect, parse, parse_ws, parse_printstream, merged_lines,
4
4
  from .emit import (emit_text, emit_markdown, emit_html, emit_rtf,
5
5
  emitter, get_emitter, formats, load_plugins)
6
6
  from .layout import modern_flow, emit_layout # registers the 'layout' format
7
+ from . import quirks # registers the built-in quirks
8
+ from .quirks import (Quirk, quirk, apply_quirks, list_quirks,
9
+ UnknownQuirk)
7
10
  from .pdf import emit_pdf # registers the 'pdf' format
8
11
  from .convert import convert, select_notes, DEFAULT_NOTE_KINDS, ALL_NOTE_KINDS
9
12
  from .info import document_info
10
13
 
11
- __version__ = '4.8.0'
14
+ __version__ = '4.9.0'
@@ -22,7 +22,7 @@ BANNER = r""" __ __ __ __
22
22
  # because the name never changes; no .flf machinery needed.
23
23
 
24
24
 
25
- from . import core, emit, pictures
25
+ from . import core, emit, pictures, quirks
26
26
  from .convert import DEFAULT_NOTE_KINDS # module attr, not the re-exported convert()
27
27
 
28
28
  def diagnose(path, data):
@@ -51,8 +51,21 @@ def write_samples(outdir):
51
51
  written.append(dest)
52
52
  return written
53
53
 
54
+ def _quirked(ap, doc, a):
55
+ """`doc` with this run's quirks resolved and applied. A bad NAME is a
56
+ usage error (a typo, or a plugin that isn't installed), not something to
57
+ swallow -- silently ignoring it would hand back output the caller did not
58
+ ask for."""
59
+ try:
60
+ return quirks.apply_quirks(doc, enable=a.quirk, disable=a.no_quirk,
61
+ mode=a.quirks)
62
+ except quirks.UnknownQuirk as e:
63
+ ap.error(str(e))
64
+
65
+
54
66
  def main(argv=None):
55
67
  emit.load_plugins() # third-party emitters (ctrlkd.emitters entry points)
68
+ quirks.load_plugins() # third-party quirks (ctrlkd.quirks entry points)
56
69
  ap = argparse.ArgumentParser(
57
70
  prog='ctrl-kd',
58
71
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -202,6 +215,36 @@ def main(argv=None):
202
215
  ap.add_argument('--comments', action='store_true',
203
216
  help="include WordStar comments, which it never printed "
204
217
  "(author's asides, hidden since the file was written)")
218
+ ap.add_argument('--quirk', action='append', metavar='NAME', default=[],
219
+ help='turn a named quirk ON for this conversion '
220
+ '(repeatable). A quirk is a NAMED departure from a '
221
+ 'literal reading of the bytes -- see --list-quirks '
222
+ 'for every name this build knows, what it does, and '
223
+ 'whether it applies to your file. Naming a quirk '
224
+ 'the document does not trip does nothing (so one '
225
+ 'standing list can be passed to every file); naming '
226
+ 'one that does not exist is an error.')
227
+ ap.add_argument('--no-quirk', action='append', metavar='NAME', default=[],
228
+ help='turn a named quirk OFF (repeatable). The quirks '
229
+ 'that are on by default are the ones the document '
230
+ "itself points at -- its own last-used printer "
231
+ 'driver changed what some characters print as -- so '
232
+ 'this is how you see the raw character instead.')
233
+ ap.add_argument('--quirks', choices=('auto', 'off', 'all'), default='auto',
234
+ help='the baseline --quirk/--no-quirk then adjust. auto '
235
+ '(DEFAULT): the quirks the document itself points '
236
+ 'at, and no others. off: none at all, the most '
237
+ 'literal reading of the bytes this converter can '
238
+ 'give. all: every quirk that applies to this '
239
+ 'document, including the ones that are off by '
240
+ 'default because they are somebody\'s judgement '
241
+ 'rather than the file\'s own evidence.')
242
+ ap.add_argument('--list-quirks', action='store_true',
243
+ help='list every quirk this build knows as JSON -- name, '
244
+ 'one-line description, and whether it is on by '
245
+ 'default; WITH file arguments, also whether each '
246
+ 'applies to that file, why, and whether it is in '
247
+ 'force. No conversion.')
205
248
  ap.add_argument('--diagnose', action='store_true',
206
249
  help='report what the file is (variant, margin, dot commands, '
207
250
  'unknown codes) as JSON; no conversion')
@@ -210,6 +253,27 @@ def main(argv=None):
210
253
  for dest in write_samples(a.samples):
211
254
  print(f'-> {dest}', file=sys.stderr)
212
255
  return 0
256
+ if a.list_quirks:
257
+ if not a.files:
258
+ print(json.dumps(quirks.list_quirks(), indent=2, ensure_ascii=False,
259
+ sort_keys=True))
260
+ return 0
261
+ status = 0
262
+ for path in a.files:
263
+ try:
264
+ data = open(path, 'rb').read()
265
+ doc = core.parse(data, encoding=a.encoding, variant=a.variant)
266
+ except (OSError, ValueError) as e:
267
+ print(f'ctrl-kd: {path}: {e}', file=sys.stderr)
268
+ status = 1
269
+ continue
270
+ doc = _quirked(ap, doc, a)
271
+ # sort_keys/ensure_ascii chosen to MATCH sr's own JSON writer exactly,
272
+ # so the two CLIs' --list-quirks output is byte-identical.
273
+ print(json.dumps({'file': os.path.basename(path),
274
+ 'quirks': quirks.list_quirks(doc)},
275
+ indent=2, ensure_ascii=False, sort_keys=True))
276
+ return status
213
277
  if not a.files:
214
278
  ap.error('the following arguments are required: files')
215
279
  mode_explicit = a.mode is not None
@@ -284,6 +348,10 @@ def main(argv=None):
284
348
  f'--variant to force)', file=sys.stderr)
285
349
  status = 1
286
350
  continue
351
+ # Quirks are resolved ONCE per document, here, before any emitter
352
+ # reads it -- so every requested format renders the same decision and
353
+ # the layout JSON's own report can never disagree with what ran.
354
+ doc = _quirked(ap, doc, a)
287
355
  # D5 notice (ruled 2026-08-05): when the user EXPLICITLY asked for
288
356
  # modern and the input can only render printed, say so once instead
289
357
  # of silently disobeying the flag. The override itself stands -- a
@@ -5226,6 +5226,26 @@ def _symmetric_blocks(data: bytes, encoding: str, raw_out=None):
5226
5226
  includes, driver[0], sorted(shift_runs), marks, header,
5227
5227
  pcl_programs)
5228
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
+
5229
5249
  def _parse_style_library(raw: bytes, base: int, encoding: str = 'cp437'):
5230
5250
  """The paragraph style library at file-absolute offset `base`.
5231
5251
 
@@ -5809,6 +5829,163 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
5809
5829
  style_font_cache[fs] = idx
5810
5830
  return idx
5811
5831
 
5832
+ def _select_style(_w0, _rel, font_sink):
5833
+ """Apply one 0x11 paragraph-style SELECTION, in the stream order it
5834
+ occurs. Returns True if it closed a block (the caller's own
5835
+ `style_closed_here`).
5836
+
5837
+ Extracted from the per-line mark loop (which is still the only
5838
+ caller for a selection that lands on a CONTENT line) so the
5839
+ dot-command branch can apply one too. A selection whose bytes sit at
5840
+ the very END of a line lands at offset 0 of whatever comes next, and
5841
+ when that is a dot-command line the mark used to die with it -- the
5842
+ same hazard `carried_marks` already names for colour and font, but
5843
+ worse, because ORDER matters here: `.lh` and a style's own line
5844
+ height are the same WordStar setting, so deferring the selection past
5845
+ the dot line would let the style silently win a contest the file says
5846
+ the dot command wins. See the dot-command branch's own call.
5847
+ """
5848
+ _closed = False
5849
+ # Resolve the handle against the file's own library. Pool tag
5850
+ # 0x02 = this file; anything else (0x03xx editing temps) is
5851
+ # unresolvable BY DESIGN and left unstyled rather than guessed.
5852
+ # Heading level comes from the RESOLVED NAME -- the corpus
5853
+ # proved slot numbers carry none (see the 0x11 parse site).
5854
+ # The selection PERSISTS: style_fmt stays in force for every
5855
+ # following block until the next 0x11. A recordless entry
5856
+ # (the inherit-everything base, e.g. 'WordStar Defaults')
5857
+ # resets formatting to the dot-command state by construction,
5858
+ # since it contributes no record fields.
5859
+ w0 = _w0
5860
+ if (w0 >> 8) == 0x02:
5861
+ slot = w0 & 0xFF
5862
+ entry = style_slots.get(slot)
5863
+ # #236 (WORDSTAR.WS/REF/wordstar-file-format.ws title
5864
+ # blocks): a style record's line_height_vmi of -1 means
5865
+ # INHERIT, already folded to None by `_parse_style_library`
5866
+ # (sword_none). Measured against WORDSTAR.WS's own capture
5867
+ # (Double-Indented Quote, a style with NO font of its own
5868
+ # AND an inherited line height): its body renders at
5869
+ # 16.0pt leading, not the document's 12pt default -- the
5870
+ # SAME 16.0pt the immediately preceding paragraph style
5871
+ # (MS Body Copy, explicit vmi=320) was already using.
5872
+ # 'Inherit' means carry the AMBIENT (previously active)
5873
+ # value forward, not reset to nothing -- style_fmt.clear()
5874
+ # below must not lose it.
5875
+ # 'style_vmi_ambient' is the last line height a STYLE declared,
5876
+ # kept apart from the ACTIVE one because a `.lh` supersedes the
5877
+ # active value (see the dot-command branch) without erasing what
5878
+ # the NEXT style's own 'inherit' should inherit. Measured on
5879
+ # `sawyer/RTF-RJS/NOVEL.WS` page 20 (ws7-prints/v4): inside a
5880
+ # book-list section running at `.lh 12pt`, the entering advance to
5881
+ # each sub-heading -- H3/H2, both of which inherit their line
5882
+ # height -- is 24.0pt, the vmi 'MS Body Copy' declared, and not
5883
+ # the 12pt `.lh` in force. Falls back to the active value, so a
5884
+ # document that never writes a `.lh` behaves exactly as before
5885
+ # (the #236 WORDSTAR.WS measurement above is unmoved).
5886
+ _prev_vmi = style_fmt.get('style_vmi_ambient',
5887
+ style_fmt.get('line_height_vmi'))
5888
+ # Ruling 2026-09-16 ("Style-library strikeout runs until a
5889
+ # style clears it"): an attribute a style turns ON stays on
5890
+ # past that paragraph. WSFORMAT.TXT's own rule for the two
5891
+ # attribute words is "if both corresponding bits are off,
5892
+ # then the attribute is inherited from the current state" --
5893
+ # so only a later style that sets the bit in its attrs_OFF
5894
+ # word ends the run. Real WS7 agrees: NOVEL.WS's H3 sets
5895
+ # strikeout on the title page and nothing in that file's
5896
+ # library ever clears it, and the LaserJet capture prints
5897
+ # the overstrike dash row on all 52 pages.
5898
+ _prev_sticky = style_fmt.get('sticky_attrs', frozenset())
5899
+ style_fmt.clear()
5900
+ if _prev_vmi is not None:
5901
+ style_fmt['style_vmi_ambient'] = _prev_vmi
5902
+ style_fmt['style_id'] = slot
5903
+ _sticky = set(_prev_sticky)
5904
+ if entry is not None:
5905
+ _on, _off = entry.get('attrs_on') or 0, entry.get('attrs_off') or 0
5906
+ for _bit, _tag in STICKY_STYLE_ATTRS:
5907
+ if _on & _bit:
5908
+ _sticky.add(_tag)
5909
+ elif _off & _bit:
5910
+ _sticky.discard(_tag)
5911
+ style_fmt['sticky_attrs'] = frozenset(_sticky)
5912
+ if _sticky:
5913
+ # Also for an UNRESOLVABLE handle (a 0x03xx editing-temp
5914
+ # pool entry, or a slot this file's library doesn't
5915
+ # carry): it declares neither word, so every attribute
5916
+ # inherits, including the running one.
5917
+ style_fmt['attrs'] = frozenset(_sticky)
5918
+ if entry is not None:
5919
+ style_fmt['style_name'] = entry['name']
5920
+ style_fmt['heading'] = _style_heading_level(entry['name'])
5921
+ if entry.get('justification') in ('left', 'justify',
5922
+ 'center', 'right'):
5923
+ # 'left' means EXPLICIT no-justification -- it
5924
+ # overrides a running .oj, so it must occupy the
5925
+ # align slot rather than fall through
5926
+ style_fmt['align'] = entry['justification']
5927
+ if entry.get('word_wrap') is not None:
5928
+ style_fmt['wrap'] = entry['word_wrap']
5929
+ for src_k, dst_k in (('left_margin_hmi', 'left_margin'),
5930
+ ('right_margin_hmi', 'right_margin'),
5931
+ ('para_margin_hmi', 'para_margin')):
5932
+ hmi = entry.get(src_k)
5933
+ if hmi is not None:
5934
+ # HMI 1/1800in -> print columns at 10 CPI,
5935
+ # the unit .lm/.rm already use (180 = 1 col)
5936
+ style_fmt[dst_k] = round(hmi / 180)
5937
+ if entry.get('attrs') or _sticky:
5938
+ # The style's own ON bits, plus every sticky
5939
+ # attribute still running from an earlier style.
5940
+ style_fmt['attrs'] = frozenset(
5941
+ entry.get('attrs') or ()) | style_fmt['sticky_attrs']
5942
+ # Register C5: the style's own declared colour index
5943
+ # (0-15, WSFORMAT's fixed CGA/EGA palette -- same
5944
+ # space as an inline type-1 colour change). `is not
5945
+ # None` mirrors line_height_vmi just below: 0 is a
5946
+ # real, explicit "Black" distinct from "the style
5947
+ # never set one", even though nothing downstream
5948
+ # currently treats 0 differently from unset.
5949
+ if entry.get('colour') is not None:
5950
+ style_fmt['style_colour'] = entry['colour']
5951
+ # line_height_vmi: -2 = auto (the only value the
5952
+ # measured oracle carries), a positive count =
5953
+ # explicit VMI (WSFORMAT.WS: same 1/1440in unit as a
5954
+ # font's own height word). sword_none already folded
5955
+ # -1 (inherit) to None at parse time, so a bare
5956
+ # `is not None` is the right test here -- 'inherit'
5957
+ # never reaches this branch.
5958
+ if entry.get('line_height_vmi') is not None:
5959
+ style_fmt['line_height_vmi'] = entry['line_height_vmi']
5960
+ style_fmt['style_vmi_ambient'] = entry['line_height_vmi']
5961
+ elif _prev_vmi is not None:
5962
+ # -1/inherit: carry the ambient value forward
5963
+ # (see the #236 comment above this block).
5964
+ style_fmt['line_height_vmi'] = _prev_vmi
5965
+ style_fmt['style_vmi_ambient'] = _prev_vmi
5966
+ # an all-zero triple records NO font (OLDTIMES's
5967
+ # 'Double-Indented Quote'), distinct from the -1
5968
+ # inherit sentinel only in never having been set
5969
+ if entry.get('font') and any(entry['font']):
5970
+ font_sink.append((_rel, _style_font(entry['font'])))
5971
+ # The style's own declared size, in points --
5972
+ # captured on the BLOCK (not just the span-level
5973
+ # font_at mark) so a spanless blank line, which
5974
+ # carries no font tag of its own, can still
5975
+ # resolve its style's auto/explicit leading.
5976
+ style_fmt['style_font_pt'] = entry['font'][1] / 20.0
5977
+ # style_fmt is updated BEFORE this close: the previous
5978
+ # block keeps its old style, the fresh block picks the
5979
+ # new one up from _new_block()
5980
+ close_block()
5981
+ _closed = True
5982
+ else:
5983
+ # 0x03xx temp-pool handle: unresolvable by design, but a
5984
+ # selection is still a block boundary in the file
5985
+ close_block()
5986
+ _closed = True
5987
+ return _closed
5988
+
5812
5989
  def _new_block():
5813
5990
  return Block('para',
5814
5991
  align=style_fmt.get('align') or _align_now(fmt),
@@ -6073,6 +6250,36 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6073
6250
  # this line would otherwise have mutated is withheld.
6074
6251
  if if_active and not all(if_active):
6075
6252
  continue
6253
+ # A style SELECTION whose bytes sit at the very END of the
6254
+ # PREVIOUS line lands at offset 0 of this one (lines_pass's
6255
+ # `a <= off < a + ln` cannot place it inside the line it
6256
+ # follows). A dot-command line is CONSUMED below, so the mark
6257
+ # used to die with it -- the same hazard the `carried_marks`
6258
+ # note further down names for colour and font. It is NOT enough
6259
+ # to carry it forward, because ORDER decides the outcome: `.lh`
6260
+ # and a paragraph style's own line height are the same WordStar
6261
+ # setting, and a style applied AFTER the `.lh` line would undo a
6262
+ # dot command the file puts second. So it is applied HERE, before
6263
+ # this line's own command runs -- which is exactly where the
6264
+ # bytes are.
6265
+ #
6266
+ # rel == 0 is what distinguishes the two cases, and it is exact:
6267
+ # a dot line's own first byte is its '.', so nothing of this
6268
+ # line's can mark offset 0, while a style block written INSIDE a
6269
+ # `.h1`/`.f1` argument (or a commented-out `..f1`) always sits
6270
+ # past the command letters and marks rel >= 4. Those belong to
6271
+ # the running head, which reads its own mark (`hf_style_w0`
6272
+ # below), and must never reach the body -- `sawyer/RTF-RJS/
6273
+ # NOVEL.WS` page 1 renders its whole front-matter block in the
6274
+ # FOOTER style's 6pt Univers if they do.
6275
+ _dot_style_fonts = []
6276
+ for _srel, _sm in line_marks:
6277
+ if _sm[0] == 'style' and _srel == 0:
6278
+ _select_style(_sm[1], 0, _dot_style_fonts)
6279
+ if _dot_style_fonts:
6280
+ # The selection's own font reaches the next CONTENT line the
6281
+ # way every other carried state does -- at its offset 0.
6282
+ carried_marks.extend(('font', _idx) for _r, _idx in _dot_style_fonts)
6076
6283
  # '..' and '.ig' are COMMENT lines (ruling 2026-08-06): both
6077
6284
  # WordStar comment syntaxes unify into Note(kind='comment'),
6078
6285
  # each emitting a reference mark at its own position -- the
@@ -6277,6 +6484,35 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6277
6484
  # single block cannot hold both.
6278
6485
  before = _block_format(fmt)
6279
6486
  _parse_format_dot(cmd, fmt)
6487
+ # `.lh` and a paragraph STYLE's own `line_height_vmi` are the
6488
+ # SAME WordStar setting -- a line height -- reached two ways, so
6489
+ # the one that comes LATER in the file wins. `_style_lead_pt`
6490
+ # gives the style's value precedence over the generic `.lh`,
6491
+ # which is right for every document that selects a style and then
6492
+ # leaves the leading alone (LYING.WS, WARPRAYR.WS: no `.lh` at
6493
+ # all). It is wrong the moment a file writes a real `.lh` AFTER
6494
+ # the selection, which is what `sawyer/RTF-RJS/NOVEL.WS` does at
6495
+ # each of its five book-list sections: select 'MS Body Copy'
6496
+ # (vmi 480 = 24pt), then `.lh 12pt`. Real WS7 prints those lists
6497
+ # single-spaced at 12pt (ws7-prints/v4, page 20: baselines 144,
6498
+ # 156, 168 ... exactly 12.0pt apart); the style's 24pt made the
6499
+ # section overflow onto a 52nd page the real print does not have.
6500
+ # Dropping the style's value here hands the following blocks back
6501
+ # to `Line.lead_48`, which is where a `.lh` already lives; the
6502
+ # next selection re-establishes its own.
6503
+ _lhm = _DOT_CMD_RE.match(cmd)
6504
+ if (_lhm and _lhm.group(1).upper() == b'LH'
6505
+ and style_fmt.get('line_height_vmi') is not None
6506
+ and _lhm.group(2).strip().lower()[:1] != b'a'):
6507
+ _lhn = _dot_num_match(_lhm.group(2))
6508
+ if _lhn:
6509
+ try:
6510
+ _lhv = float(_lhn.group(1))
6511
+ except (TypeError, ValueError):
6512
+ _lhv = None
6513
+ if _lhv is not None and _resolve_lh_arg(_lhv, _lhn.group(2)):
6514
+ style_fmt.pop('line_height_vmi', None)
6515
+ close_block()
6280
6516
  # #241 remainder: a same-line `.RR` ruler IMAGE (`.RR--!...R`)
6281
6517
  # updates left_margin/para_margin/right_margin exactly like an
6282
6518
  # explicit `.lm`/`.pm`/`.rm` would -- see `_apply_ruler_margins`'s
@@ -6474,98 +6710,7 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
6474
6710
  # for the measurement.
6475
6711
  cur_line.softpage = True
6476
6712
  elif m[0] == 'style':
6477
- # Resolve the handle against the file's own library. Pool tag
6478
- # 0x02 = this file; anything else (0x03xx editing temps) is
6479
- # unresolvable BY DESIGN and left unstyled rather than guessed.
6480
- # Heading level comes from the RESOLVED NAME -- the corpus
6481
- # proved slot numbers carry none (see the 0x11 parse site).
6482
- # The selection PERSISTS: style_fmt stays in force for every
6483
- # following block until the next 0x11. A recordless entry
6484
- # (the inherit-everything base, e.g. 'WordStar Defaults')
6485
- # resets formatting to the dot-command state by construction,
6486
- # since it contributes no record fields.
6487
- w0 = m[1]
6488
- if (w0 >> 8) == 0x02:
6489
- slot = w0 & 0xFF
6490
- entry = style_slots.get(slot)
6491
- # #236 (WORDSTAR.WS/REF/wordstar-file-format.ws title
6492
- # blocks): a style record's line_height_vmi of -1 means
6493
- # INHERIT, already folded to None by `_parse_style_library`
6494
- # (sword_none). Measured against WORDSTAR.WS's own capture
6495
- # (Double-Indented Quote, a style with NO font of its own
6496
- # AND an inherited line height): its body renders at
6497
- # 16.0pt leading, not the document's 12pt default -- the
6498
- # SAME 16.0pt the immediately preceding paragraph style
6499
- # (MS Body Copy, explicit vmi=320) was already using.
6500
- # 'Inherit' means carry the AMBIENT (previously active)
6501
- # value forward, not reset to nothing -- style_fmt.clear()
6502
- # below must not lose it.
6503
- _prev_vmi = style_fmt.get('line_height_vmi')
6504
- style_fmt.clear()
6505
- style_fmt['style_id'] = slot
6506
- if entry is not None:
6507
- style_fmt['style_name'] = entry['name']
6508
- style_fmt['heading'] = _style_heading_level(entry['name'])
6509
- if entry.get('justification') in ('left', 'justify',
6510
- 'center', 'right'):
6511
- # 'left' means EXPLICIT no-justification -- it
6512
- # overrides a running .oj, so it must occupy the
6513
- # align slot rather than fall through
6514
- style_fmt['align'] = entry['justification']
6515
- if entry.get('word_wrap') is not None:
6516
- style_fmt['wrap'] = entry['word_wrap']
6517
- for src_k, dst_k in (('left_margin_hmi', 'left_margin'),
6518
- ('right_margin_hmi', 'right_margin'),
6519
- ('para_margin_hmi', 'para_margin')):
6520
- hmi = entry.get(src_k)
6521
- if hmi is not None:
6522
- # HMI 1/1800in -> print columns at 10 CPI,
6523
- # the unit .lm/.rm already use (180 = 1 col)
6524
- style_fmt[dst_k] = round(hmi / 180)
6525
- if entry.get('attrs'):
6526
- style_fmt['attrs'] = entry['attrs']
6527
- # Register C5: the style's own declared colour index
6528
- # (0-15, WSFORMAT's fixed CGA/EGA palette -- same
6529
- # space as an inline type-1 colour change). `is not
6530
- # None` mirrors line_height_vmi just below: 0 is a
6531
- # real, explicit "Black" distinct from "the style
6532
- # never set one", even though nothing downstream
6533
- # currently treats 0 differently from unset.
6534
- if entry.get('colour') is not None:
6535
- style_fmt['style_colour'] = entry['colour']
6536
- # line_height_vmi: -2 = auto (the only value the
6537
- # measured oracle carries), a positive count =
6538
- # explicit VMI (WSFORMAT.WS: same 1/1440in unit as a
6539
- # font's own height word). sword_none already folded
6540
- # -1 (inherit) to None at parse time, so a bare
6541
- # `is not None` is the right test here -- 'inherit'
6542
- # never reaches this branch.
6543
- if entry.get('line_height_vmi') is not None:
6544
- style_fmt['line_height_vmi'] = entry['line_height_vmi']
6545
- elif _prev_vmi is not None:
6546
- # -1/inherit: carry the ambient value forward
6547
- # (see the #236 comment above this block).
6548
- style_fmt['line_height_vmi'] = _prev_vmi
6549
- # an all-zero triple records NO font (OLDTIMES's
6550
- # 'Double-Indented Quote'), distinct from the -1
6551
- # inherit sentinel only in never having been set
6552
- if entry.get('font') and any(entry['font']):
6553
- font_at.append((rel, _style_font(entry['font'])))
6554
- # The style's own declared size, in points --
6555
- # captured on the BLOCK (not just the span-level
6556
- # font_at mark) so a spanless blank line, which
6557
- # carries no font tag of its own, can still
6558
- # resolve its style's auto/explicit leading.
6559
- style_fmt['style_font_pt'] = entry['font'][1] / 20.0
6560
- # style_fmt is updated BEFORE this close: the previous
6561
- # block keeps its old style, the fresh block picks the
6562
- # new one up from _new_block()
6563
- close_block()
6564
- style_closed_here = True
6565
- else:
6566
- # 0x03xx temp-pool handle: unresolvable by design, but a
6567
- # selection is still a block boundary in the file
6568
- close_block()
6713
+ if _select_style(m[1], rel, font_at):
6569
6714
  style_closed_here = True
6570
6715
  # every other mark kind is SPAN-LEVEL and was collected in the
6571
6716
  # pass above this one, before the form-feed branch that needs it