ctrl-kd 1.1.2__py3-none-any.whl

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.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctrl-kd
3
+ Version: 1.1.2
4
+ Summary: Convert WordStar 4-7 documents and print-to-disk files to text, Markdown, HTML, RTF, or PDF. ^KD: save and done.
5
+ Author: Jon Michaels
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jonmichaels/ctrl-kd
8
+ Keywords: wordstar,converter,retrocomputing,archive,dos
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Console
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Text Processing :: Filters
14
+ Classifier: Topic :: System :: Archiving
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # ctrl-kd
21
+
22
+ Convert WordStar-era files to modern formats. **^KD: save and done.**
23
+
24
+ `ctrl-kd` reads WordStar 4 documents, WordStar 5–7 documents, and WordStar
25
+ **print-to-disk files** (the printer byte stream, captured to a file — a distinct
26
+ format most converters mangle), and writes plain text, Markdown, HTML, RTF, or PDF (typewriter-set on the
27
+ built-in Courier fonts — no dependencies, the page as it would have printed).
28
+
29
+ ```console
30
+ $ ctrl-kd ESSAY.WS # -> ESSAY.md
31
+ $ ctrl-kd ESSAY.WS -t html -t rtf # multiple formats
32
+ $ ctrl-kd ESSAY.WS -t pdf --mode printed # a facsimile of the 1990 printout
33
+ $ ctrl-kd --mode printed LETTER.WS # line-for-line, as it printed in 1990
34
+ $ ctrl-kd --diagnose MYSTERY.FIL # what IS this file?
35
+ ```
36
+
37
+ ## Why another converter?
38
+
39
+ Existing tools each lose something. Fed a WordStar 4 file, converters written for
40
+ WS7 delete the last letter of every word (WS4 set bit 7 on it). Most delete soft
41
+ returns outright — `Jon Michaels` + `March 6, 1992` becomes
42
+ `Jon MichaelsMarch 6, 1992` — which also destroys every poem, because poem lines
43
+ end in soft returns too. And print-to-disk files aren't WordStar documents at all,
44
+ so feeding them to a WordStar converter produces stray superscripts and garbage.
45
+
46
+ `ctrl-kd` was built by converting a real 1987–1992 corpus (high-school and college
47
+ papers, poems, stories — WordStar 4 on DOS, dot-matrix printer) and verifying
48
+ against surviving period printouts of the same documents. Its rules are empirical:
49
+
50
+ * **Detection by content, never by extension.** WS4 vs WS5+ vs print stream vs
51
+ plain text vs binary, with the evidence shown in `--diagnose`.
52
+ * **The wrap test.** WordStar wrapped only when the next word didn't fit. So a
53
+ soft return where the next word *would* have fit (strictly — WordStar wrapped
54
+ even on an exact-margin fit) is a deliberate break: a poem line, a heading.
55
+ Everything else is word wrap and joins with a space. The margin is estimated
56
+ from the 90th percentile of soft-wrapped line lengths (floor 65, the default).
57
+ * **Break runs.** Soft/hard return runs containing a hard return and a blank line
58
+ are paragraph breaks; a lone hard return is the author's deliberate line break.
59
+ Double-spaced documents (blank soft lines between every line) collapse
60
+ automatically.
61
+ * **Ruler lines mean columns.** A `.rr----!----` dot line defines tab stops; the
62
+ document's alignment is space-built and only survives fixed-width. Such
63
+ documents render `printed` in every mode.
64
+ * **Print streams render verbatim** — they ARE the printed page — with printer
65
+ style codes decoded (superscript/underline/italic/bold pairs; table in
66
+ `core.PRINT_CODES`, derived from a late-80s dot-matrix driver and overridable).
67
+ * **WS5+ symmetric blocks** (`0x1D`: real footnotes/endnotes, headings, page
68
+ breaks — machinery added in WS5) are parsed with their nested structure,
69
+ verified against the 86 WordStar 7 documents in Robert J. Sawyer's public
70
+ WordStar archive: footnotes extract with in-text references (`[^n]` in
71
+ Markdown), paragraph styles become headings, and 82/86 convert with zero
72
+ mojibake. More WS5–7 corpora still welcome.
73
+
74
+ ## Modes
75
+
76
+ * `--mode modern` (default): reflowed paragraphs, semantic markup, deliberate
77
+ line breaks kept.
78
+ * `--mode printed`: every line as laid out, fixed-width, `.pa`/form-feed page
79
+ breaks honored — how it came off the printer.
80
+
81
+ ## Install
82
+
83
+ Straight from GitHub (not yet on PyPI):
84
+
85
+ ```console
86
+ $ pipx install git+https://github.com/jonmichaels/ctrl-kd
87
+ ```
88
+
89
+ or `pip install git+https://github.com/jonmichaels/ctrl-kd` into an environment
90
+ of your choice. Python ≥ 3.9, no dependencies.
91
+ Library API: `ctrlkd.convert(data, to='html')`.
92
+
93
+ ## Adding an output format
94
+
95
+ An output format is one function over the parsed document — register it with the
96
+ `@ctrlkd.emitter` decorator, or ship it as a pip-installable plugin via the
97
+ `ctrlkd.emitters` entry-point group and it appears in the CLI automatically.
98
+ **[EXTENDING.md](EXTENDING.md)** has the IR contract, a complete worked example
99
+ (BBCode in ~40 lines), and a checklist.
100
+
101
+ ## Lineage
102
+
103
+ Standing on the shoulders of the tools and documentation that kept WordStar
104
+ readable: Yohanes Nugroho's WS-CON, Michael Petrie's English port, the `wsconvert`
105
+ project, Robert J. Sawyer's WordStar archive, and the WordStar format
106
+ documentation community. Behaviors were studied and reimplemented; no code was
107
+ copied. The development corpus is personal and is not distributed — tests use
108
+ synthetic fixtures that encode the same behaviors.
109
+
110
+ ## Credits
111
+
112
+ Written by Jon Michaels — whose 1987–1992 WordStar files, and the need to read
113
+ them again, are the reason this exists — with Athena (Claude, Anthropic) as
114
+ co-author: the byte archaeology, the wrap test, and the implementation grew out
115
+ of a joint effort to recover those disks. Every commit carries the co-author
116
+ trailer.
117
+
118
+ ## License
119
+
120
+ MIT © Jon Michaels
@@ -0,0 +1,11 @@
1
+ ctrl_kd-1.1.2.dist-info/licenses/LICENSE,sha256=BEHxdhJXtQzaKyykQunEdL8p67aK6x9fdDYkbeBlRpY,1069
2
+ ctrlkd/__init__.py,sha256=Zgb2ShkQMe_sdGKtFM_5xuAipytBH1I4xpR5I9DQ56I,669
3
+ ctrlkd/cli.py,sha256=RcQkcC3etnZhA3rPJXQlqgkYIH4CsTUJFmWyKxHUkxg,4114
4
+ ctrlkd/core.py,sha256=aPuaCl0y76_pn7_ZPtL-2akhcZmlQAFgtQmZBczU7YM,15807
5
+ ctrlkd/emit.py,sha256=Y8TszTHA74r4l18NFxI28D5Lcv5B9s9SOaJ4BO748fg,9305
6
+ ctrlkd/pdf.py,sha256=uGsGNtWIZWF3w-IAn4XUQryXIL335xf0IY-GCEB3lj8,8493
7
+ ctrl_kd-1.1.2.dist-info/METADATA,sha256=KzD6IGjeHjnYqIMhnBxla9qjrb-rUGBgBN_1W3PqU7Q,5648
8
+ ctrl_kd-1.1.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ ctrl_kd-1.1.2.dist-info/entry_points.txt,sha256=K8MXIMbfy87rE2ef0ll7MgbgFMVONpr4IAHDhIwGi2w,44
10
+ ctrl_kd-1.1.2.dist-info/top_level.txt,sha256=ohMOsb7bEutCwzkdhY43QEv1ydDAKXH2hArckeKKBSY,7
11
+ ctrl_kd-1.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ctrl-kd = ctrlkd.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jon Michaels
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ctrlkd
ctrlkd/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """ctrl-kd — convert WordStar-era files to modern formats. ^KD: save and done."""
2
+ from .core import detect, parse, parse_ws, parse_printstream, Document, Block, Line, Span
3
+ from .emit import (emit_text, emit_markdown, emit_html, emit_rtf,
4
+ emitter, get_emitter, formats, load_plugins)
5
+ from .pdf import emit_pdf # registers the 'pdf' format
6
+
7
+ __version__ = '1.1.2'
8
+
9
+ def convert(data: bytes, to: str = 'markdown', mode: str = 'modern',
10
+ encoding: str = 'cp437', **options) -> str:
11
+ """One-call API: bytes in, converted string out."""
12
+ return get_emitter(to)['fn'](parse(data, encoding=encoding), mode, **options)
ctrlkd/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """ctrl-kd command line: convert WordStar-era files to modern formats.
2
+
3
+ ctrl-kd PAPER.WS # -> PAPER.md, modern reflow
4
+ ctrl-kd PAPER.WS -t html -o out.html
5
+ ctrl-kd --mode printed LETTER # as it came off the printer
6
+ ctrl-kd --diagnose MYSTERY.FIL # what IS this file?
7
+ ctrl-kd -t text -t html -d out/ *.WS # batch, multiple formats
8
+ """
9
+ import argparse, json, os, sys
10
+ from . import core, emit
11
+
12
+ def diagnose(path, data):
13
+ det = core.detect(data)
14
+ info = {'file': path, **det}
15
+ if det['variant'] in ('ws4', 'ws5+'):
16
+ doc = core.parse_ws(data)
17
+ info.update({k: doc.meta[k] for k in
18
+ ('margin_estimate', 'dot_commands', 'unknown_codes', 'columnar')})
19
+ info['paragraphs'] = sum(1 for b in doc.blocks if b.kind == 'para')
20
+ info['footnotes'] = len(doc.footnotes)
21
+ return info
22
+
23
+ def main(argv=None):
24
+ emit.load_plugins() # third-party emitters (ctrlkd.emitters entry points)
25
+ ap = argparse.ArgumentParser(
26
+ prog='ctrl-kd',
27
+ description='Convert WordStar 4-7 documents and print-to-disk files to '
28
+ 'text, Markdown, HTML, or RTF (extensible: see EXTENDING.md). '
29
+ '^KD: save and done.')
30
+ ap.add_argument('files', nargs='+', help='input file(s)')
31
+ ap.add_argument('-t', '--to', action='append', choices=emit.formats(),
32
+ help='output format (repeatable; default: markdown)')
33
+ ap.add_argument('-o', '--output', help='output file (single input only)')
34
+ ap.add_argument('-d', '--outdir', help='output directory for batch conversion')
35
+ ap.add_argument('--mode', choices=('modern', 'printed'), default='modern',
36
+ help='modern: reflowed paragraphs. printed: line-for-line, '
37
+ 'fixed-width, as it printed in 1990 (default: modern; '
38
+ 'print streams and ruler-line documents always render printed)')
39
+ ap.add_argument('--variant', choices=('ws4', 'ws5+', 'printstream', 'text'),
40
+ help='override detection')
41
+ ap.add_argument('--encoding', default='cp437',
42
+ help='byte encoding of the source (default: cp437)')
43
+ ap.add_argument('--diagnose', action='store_true',
44
+ help='report what the file is (variant, margin, dot commands, '
45
+ 'unknown codes) as JSON; no conversion')
46
+ a = ap.parse_args(argv)
47
+ formats = a.to or ['markdown']
48
+ if a.output and (len(a.files) > 1 or len(formats) > 1):
49
+ ap.error('-o works with a single input and a single format; use -d for batch')
50
+
51
+ status = 0
52
+ for path in a.files:
53
+ try:
54
+ data = open(path, 'rb').read()
55
+ except OSError as e:
56
+ print(f'ctrl-kd: {e}', file=sys.stderr)
57
+ status = 1
58
+ continue
59
+ if a.diagnose:
60
+ print(json.dumps(diagnose(path, data), indent=2))
61
+ continue
62
+ try:
63
+ doc = core.parse(data, encoding=a.encoding, variant=a.variant)
64
+ except ValueError as e:
65
+ print(f'ctrl-kd: {path}: {e} (use --diagnose to inspect, '
66
+ f'--variant to force)', file=sys.stderr)
67
+ status = 1
68
+ continue
69
+ base = os.path.splitext(os.path.basename(path))[0]
70
+ for fmt in formats:
71
+ reg = emit.get_emitter(fmt)
72
+ out = reg['fn'](doc, a.mode, title=base)
73
+ if a.output:
74
+ dest = a.output
75
+ else:
76
+ dest = os.path.join(a.outdir or os.path.dirname(path) or '.',
77
+ base + reg['ext'])
78
+ if a.outdir:
79
+ os.makedirs(a.outdir, exist_ok=True)
80
+ if isinstance(out, bytes): # binary formats (e.g. pdf)
81
+ with open(dest, 'wb') as f:
82
+ f.write(out)
83
+ else:
84
+ with open(dest, 'w', encoding='utf-8', newline='\n') as f:
85
+ f.write(out)
86
+ print(f'{path} -> {dest}')
87
+ return status
88
+
89
+ if __name__ == '__main__':
90
+ sys.exit(main())
ctrlkd/core.py ADDED
@@ -0,0 +1,392 @@
1
+ """ctrl-kd core: detection, parsing, and the intermediate representation.
2
+
3
+ Everything parses into one IR (a Document of Blocks of Lines of Spans), and every
4
+ export format is a small emitter over that IR — the architectural lesson from
5
+ wsconvert and WS-CON, which each go bytes->output in a single pass and each lose
6
+ something on the way.
7
+
8
+ WordStar background this code encodes:
9
+
10
+ * WS4 and earlier set bit 7 on the LAST character of each word ("microjustify"
11
+ flags). WS5+ dropped that; high bytes there are extended (cp437) characters.
12
+ * Soft returns (8D 0A) mark where WordStar word-wrapped; hard returns (0D 0A) are
13
+ the author pressing Return. WS4 stores the on-screen layout, so recovering
14
+ intent takes the wrap test (see lines_pass).
15
+ * Print-to-disk files are not WordStar documents at all: they are the byte stream
16
+ sent to the printer, captured to a file. They ARE the printed page.
17
+ """
18
+ from __future__ import annotations
19
+ import re
20
+ from dataclasses import dataclass, field
21
+
22
+ # ---------------------------------------------------------------- IR
23
+
24
+ @dataclass
25
+ class Span:
26
+ text: str
27
+ styles: frozenset = frozenset() # subset of {'b','i','u','sup','sub','strike'}
28
+
29
+ @dataclass
30
+ class Line:
31
+ spans: list = field(default_factory=list)
32
+
33
+ def text(self):
34
+ return ''.join(s.text for s in self.spans)
35
+
36
+ @dataclass
37
+ class Block:
38
+ kind: str # 'para' | 'pagebreak' | 'softpage'
39
+ lines: list = field(default_factory=list)
40
+ heading: int = 0 # 0 = body text; 1-3 = WS5+ title/header/subheading
41
+
42
+ @dataclass
43
+ class Document:
44
+ blocks: list = field(default_factory=list)
45
+ footnotes: list = field(default_factory=list) # list[list[Span]] (WS5+)
46
+ meta: dict = field(default_factory=dict) # detection + diagnose info
47
+
48
+ def iter_lines(self):
49
+ for b in self.blocks:
50
+ yield from b.lines
51
+
52
+ # ---------------------------------------------------------------- detection
53
+
54
+ def detect(data: bytes) -> dict:
55
+ """Classify a file by CONTENT (names and extensions lie).
56
+
57
+ Returns dict with 'variant': ws4 | ws5+ | printstream | text | binary
58
+ plus the evidence, suitable for --diagnose output.
59
+ """
60
+ core = data[:data.index(0x1A)] if 0x1A in data else data
61
+ if not core:
62
+ return {'variant': 'binary', 'reason': 'empty (or ^Z at start)'}
63
+ soft = core.count(b'\x8d\x0a')
64
+ hard = core.count(b'\x0d\x0a')
65
+ hi = sum(1 for x in core if x >= 0x80)
66
+ blocks_1d = core.count(b'\x1d')
67
+ txt = sum(1 for x in core if 0x20 <= (x & 0x7F) < 0x7F or x in (0x0D, 0x0A, 0x09)) * 100 // len(core)
68
+ ev = {'soft_returns': soft, 'hard_returns': hard, 'high_bit_bytes': hi,
69
+ 'text_pct': txt, 'symmetric_blocks_1d': blocks_1d, 'size': len(core)}
70
+ if txt < 40:
71
+ return {'variant': 'binary', 'reason': f'only {txt}% text-like', **ev}
72
+ if blocks_1d >= 2:
73
+ # 1D symmetric blocks are WS5+ machinery regardless of anything else
74
+ return {'variant': 'ws5+', **ev}
75
+ # soft returns are strong WS evidence on their own; high-bit density alone is
76
+ # not — binaries are full of high bytes — unless the file is mostly text
77
+ if soft >= 3 or (hi >= max(1, len(core) // 20) and txt >= 70):
78
+ # WS5+ kept soft returns but dropped the bit-7-on-last-letter convention:
79
+ # a wordstar file with many soft returns and near-zero high bits is WS5+,
80
+ # as is one using 1D symmetric blocks (footnotes etc., WS5+ only)
81
+ if blocks_1d >= 2 or (soft >= 3 and hi < soft // 4):
82
+ return {'variant': 'ws5+', **ev}
83
+ return {'variant': 'ws4', **ev}
84
+ if txt >= 90 and hard >= 2:
85
+ return {'variant': 'printstream', **ev}
86
+ if txt >= 90:
87
+ return {'variant': 'text', **ev}
88
+ return {'variant': 'binary', 'reason': f'{txt}% text but no structure', **ev}
89
+
90
+ # ---------------------------------------------------------------- line engine
91
+
92
+ def _visible(text: bytes) -> bytes:
93
+ return bytes(b & 0x7F for b in text if 0x20 <= (b & 0x7F) < 0x7F)
94
+
95
+ def lines_pass(data: bytes):
96
+ """Split into physical lines and classify every break.
97
+
98
+ Yields (line_bytes, sep) with sep in {'wrap','line','para','eof'}:
99
+ para a break run with >=1 hard return and >=2 breaks total (blank line)
100
+ line a single hard return (the author's Return), or a soft return where
101
+ the next word WOULD have fit — WS4 wrapped only when it didn't fit,
102
+ so breaking early was a choice (poem line, heading). Strict <:
103
+ WS4 wrapped even when the word would land exactly at the margin.
104
+ wrap a soft return that is just word wrap: join with a space
105
+ Margin is the 90th percentile of soft-wrapped line lengths (outliers from
106
+ hanging punctuation sit 1-2 past the true margin), floor 65 (WS4 default).
107
+ """
108
+ cut = data.find(b'\x1a')
109
+ if cut != -1:
110
+ data = data[:cut]
111
+ parts = re.split(rb'(\x8d\x0a|\x0d\x0a|\x8d|\x0d|\x0a)', data)
112
+ lines = []
113
+ for i in range(0, len(parts), 2):
114
+ text = parts[i]
115
+ brk = parts[i + 1] if i + 1 < len(parts) else b''
116
+ kind = 'eof' if not brk else ('soft' if brk[0] == 0x8D else 'hard')
117
+ if text or kind != 'eof':
118
+ lines.append((text, kind))
119
+
120
+ softlens = sorted(len(_visible(t).rstrip()) for t, k in lines
121
+ if k == 'soft' and _visible(t).strip())
122
+ margin = max(65, softlens[int(len(softlens) * 0.9)] if softlens else 0)
123
+
124
+ out = []
125
+ i = 0
126
+ while i < len(lines):
127
+ text, kind = lines[i]
128
+ if not _visible(text).strip():
129
+ i += 1
130
+ continue
131
+ n_hard = 1 if kind == 'hard' else 0
132
+ n_total = 0 if kind == 'eof' else 1
133
+ j = i + 1
134
+ while j < len(lines) and not _visible(lines[j][0]).strip():
135
+ k = lines[j][1]
136
+ if k != 'eof':
137
+ n_total += 1
138
+ n_hard += 1 if k == 'hard' else 0
139
+ j += 1
140
+ if j >= len(lines):
141
+ out.append((text, 'eof'))
142
+ break
143
+ if n_hard >= 1 and n_total >= 2:
144
+ sep = 'para'
145
+ elif n_hard == 1:
146
+ sep = 'line'
147
+ else:
148
+ nxt_vis = _visible(lines[j][0])
149
+ if nxt_vis[:1] == b' ':
150
+ sep = 'line' # indented continuation = deliberate
151
+ else:
152
+ L = len(_visible(text).rstrip())
153
+ W = len(nxt_vis.split(b' ', 1)[0])
154
+ sep = 'line' if L + 1 + W < margin else 'wrap'
155
+ out.append((text, sep))
156
+ i = j
157
+ return out, margin
158
+
159
+ # ---------------------------------------------------------------- WS documents
160
+
161
+ # WordStar inline control codes (same core set WS4 through WS7)
162
+ WS_TOGGLES = {0x02: 'b', 0x13: 'u', 0x19: 'i', 0x14: 'sup', 0x16: 'sub',
163
+ 0x18: 'strike', 0x04: 'b'} # ^D doublestrike -> bold
164
+ WS_DROP = {0x01, 0x03, 0x08, 0x0B, 0x0E, 0x10, 0x11, 0x12, 0x15, 0x17, 0x1C}
165
+
166
+ DOT_PAGEBREAK = {b'PA', b'CP'}
167
+
168
+ def _decode_spans(raw: bytes, strip_hibit: bool, encoding: str, active: set,
169
+ unknown: dict, fn_counter: list = None) -> list:
170
+ """One physical line of bytes -> list of Span. `active` persists across lines
171
+ (WordStar styles span line breaks). fn_counter (ws5+ only) numbers the
172
+ footnote-reference sentinels injected by _symmetric_blocks."""
173
+ spans, buf = [], bytearray()
174
+
175
+ def flush():
176
+ if buf:
177
+ spans.append(Span(buf.decode(encoding, 'replace'), frozenset(active)))
178
+ buf.clear()
179
+
180
+ i = 0
181
+ while i < len(raw):
182
+ # WS4's bit-7-on-last-letter applies to CONTROL TOGGLES too (a word ending
183
+ # at a style boundary yields e.g. 0x94 = ^T|0x80) — so mask BEFORE dispatch,
184
+ # or high-bit toggles leak into text and styles never close.
185
+ b = raw[i] & 0x7F if strip_hibit and raw[i] >= 0x80 else raw[i]
186
+ if b == 0x1B and i + 1 < len(raw): # extended char escape
187
+ buf.append(raw[i + 1]); i += 2; continue
188
+ if fn_counter is not None and b == SENT_FNREF:
189
+ flush()
190
+ fn_counter[0] += 1
191
+ spans.append(Span(str(fn_counter[0]), frozenset(active | {'sup', 'fnref'})))
192
+ elif b in WS_TOGGLES:
193
+ flush()
194
+ style = WS_TOGGLES[b]
195
+ (active.remove if style in active else active.add)(style)
196
+ elif b == 0x0F:
197
+ buf.append(0x20) # binding space
198
+ elif b == 0x1E:
199
+ pass # inactive soft hyphen
200
+ elif b == 0x1F:
201
+ buf.append(0x2D) # active soft hyphen
202
+ elif b == 0x09:
203
+ buf.append(b)
204
+ elif b < 0x20 or b == 0x7F:
205
+ if b not in WS_DROP:
206
+ unknown[b] = unknown.get(b, 0) + 1
207
+ else:
208
+ buf.append(b)
209
+ i += 1
210
+ flush()
211
+ return spans
212
+
213
+ SENT_FNREF = 0x07 # sentinels injected into the cleaned stream; these bytes
214
+ SENT_SOFTPAGE = 0x0B # cannot appear as text in a WS5+ document body
215
+ SENT_HEADING = 0x11
216
+
217
+ def _note_text(block: bytes, encoding: str) -> str:
218
+ """Note content is NESTED: header, then an inner 1D, the text, then a 2-byte
219
+ length + 1D tail (verified on the Sawyer WS7 archive: 'Footnote\\r\\n,\\x00')."""
220
+ inner = block.split(b'\x1d')
221
+ text = inner[1][:-2] if len(inner) > 1 and len(inner[1]) > 2 else block[20:]
222
+ clean = bytes(c for c in text if 0x20 <= c < 0x7F or c >= 0x80 or c == 0x09)
223
+ return clean.decode(encoding, 'replace').strip()
224
+
225
+ def _symmetric_blocks(data: bytes, encoding: str):
226
+ """Strip WS5+ 1D symmetric sequences (2-byte LE length, command type at +2),
227
+ collecting footnotes/endnotes and injecting sentinels for the block types that
228
+ carry document structure. Verified against the 86 WS7 documents in Robert J.
229
+ Sawyer's WordStar archive."""
230
+ out = bytearray()
231
+ footnotes = []
232
+ i = 0
233
+ while i < len(data):
234
+ if data[i] == 0x1D and i + 3 <= len(data):
235
+ jump = int.from_bytes(data[i + 1:i + 3], 'little')
236
+ block = data[i + 1:i + 3 + jump]
237
+ cmd = block[2] if len(block) > 2 else -1
238
+ if cmd in (0x03, 0x04): # foot/endnote
239
+ footnotes.append(_note_text(block, encoding))
240
+ out.append(SENT_FNREF)
241
+ elif cmd == 0x09: # tab
242
+ out += b' '
243
+ elif cmd == 0x0B: # end of page
244
+ out.append(SENT_SOFTPAGE)
245
+ elif cmd == 0x11 and len(block) > 3: # paragraph style
246
+ level = {0x05: 1, 0x02: 2, 0x03: 3}.get(block[3], 0)
247
+ if level:
248
+ out += bytes([SENT_HEADING, 0x30 + level])
249
+ i += jump + 3
250
+ else:
251
+ out.append(data[i])
252
+ i += 1
253
+ return bytes(out), footnotes
254
+
255
+ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
256
+ doc = Document()
257
+ det = detect(data)
258
+ doc.meta.update(det)
259
+ strip_hibit = det['variant'] == 'ws4'
260
+
261
+ ws5 = det['variant'] == 'ws5+'
262
+ if ws5:
263
+ data, notes = _symmetric_blocks(data, encoding)
264
+ doc.footnotes = [[Span(n)] for n in notes]
265
+
266
+ physical, margin = lines_pass(data)
267
+ doc.meta['margin_estimate'] = margin
268
+
269
+ active, unknown, dots = set(), {}, []
270
+ fn_counter = [0] if ws5 else None
271
+ cur = Block('para')
272
+ cur_line = Line()
273
+ ruler = False
274
+
275
+ def close_line():
276
+ nonlocal cur_line
277
+ if cur_line.spans:
278
+ cur.lines.append(cur_line)
279
+ cur_line = Line()
280
+
281
+ def close_block():
282
+ nonlocal cur
283
+ close_line()
284
+ if cur.lines:
285
+ doc.blocks.append(cur)
286
+ cur = Block('para')
287
+
288
+ for raw, sep in physical:
289
+ stripped = bytes(b & 0x7F for b in raw)
290
+ if stripped[:1] == b'.': # dot command line
291
+ cmd = stripped.rstrip()
292
+ dots.append(cmd.decode(encoding, 'replace'))
293
+ if cmd[1:3].upper() in DOT_PAGEBREAK:
294
+ close_block()
295
+ doc.blocks.append(Block('pagebreak'))
296
+ if cmd[1:2].lower() == b'r' and b'!' in cmd:
297
+ ruler = True
298
+ continue
299
+ if ws5: # sentinels from _symmetric_blocks
300
+ if raw.count(SENT_SOFTPAGE):
301
+ close_block()
302
+ doc.blocks.append(Block('softpage'))
303
+ raw = raw.replace(bytes([SENT_SOFTPAGE]), b'')
304
+ if raw[:1] == bytes([SENT_HEADING]) and len(raw) > 1:
305
+ close_block()
306
+ cur.heading = raw[1] - 0x30
307
+ raw = raw[2:]
308
+ raw = raw.replace(bytes([SENT_HEADING]), b'')
309
+ spans = _decode_spans(raw, strip_hibit, encoding, active, unknown, fn_counter)
310
+ for s in spans:
311
+ cur_line.spans.append(s)
312
+ if sep == 'wrap':
313
+ t = cur_line.spans[-1].text if cur_line.spans else ''
314
+ if t and not t.endswith((' ', '-')):
315
+ cur_line.spans.append(Span(' ', cur_line.spans[-1].styles))
316
+ elif sep == 'line':
317
+ close_line()
318
+ else: # para / eof
319
+ close_block()
320
+ close_block()
321
+
322
+ doc.meta['dot_commands'] = dots
323
+ doc.meta['unknown_codes'] = {f'0x{k:02x}': v for k, v in sorted(unknown.items())}
324
+ doc.meta['columnar'] = ruler
325
+ return doc
326
+
327
+ # ---------------------------------------------------------------- print streams
328
+
329
+ # Empirically derived from a late-80s dot-matrix driver (see README); pass a
330
+ # custom table if your printer differed.
331
+ PRINT_CODES = {0x18: ('sup', True), 0x12: ('sup', False),
332
+ 0x10: ('u', True), 0x11: ('u', False),
333
+ 0x13: ('i', True), 0x15: ('i', False),
334
+ 0x05: ('i', True), 0x06: ('i', False),
335
+ 0x1E: ('b', True), 0x1F: ('b', False)}
336
+
337
+ def parse_printstream(data: bytes, encoding: str = 'cp437',
338
+ codes: dict = None) -> Document:
339
+ """A print-to-disk capture IS the printed page: every line verbatim, printer
340
+ style codes decoded, everything else below 0x20 stripped."""
341
+ codes = PRINT_CODES if codes is None else codes
342
+ doc = Document(meta={'variant': 'printstream', 'columnar': True})
343
+ cut = data.find(b'\x1a')
344
+ if cut != -1:
345
+ data = data[:cut]
346
+ active = set()
347
+ cur = Block('para')
348
+ line = Line()
349
+ buf = bytearray()
350
+
351
+ def flush():
352
+ if buf:
353
+ line.spans.append(Span(buf.decode(encoding, 'replace'), frozenset(active)))
354
+ buf.clear()
355
+
356
+ def endline():
357
+ nonlocal line
358
+ flush()
359
+ cur.lines.append(line) # blank lines are page geometry: keep
360
+ line = Line()
361
+
362
+ for b in data:
363
+ c = b & 0x7F
364
+ if c in codes:
365
+ flush()
366
+ style, on = codes[c]
367
+ (active.add if on else active.discard)(style)
368
+ elif c == 0x0A:
369
+ endline()
370
+ elif c == 0x0C:
371
+ endline()
372
+ doc.blocks.append(cur)
373
+ doc.blocks.append(Block('pagebreak'))
374
+ cur = Block('para')
375
+ elif c == 0x0D or (c < 0x20 and c != 0x09):
376
+ continue
377
+ else:
378
+ buf.append(c)
379
+ endline()
380
+ doc.blocks.append(cur)
381
+ return doc
382
+
383
+ # ---------------------------------------------------------------- front door
384
+
385
+ def parse(data: bytes, encoding: str = 'cp437', variant: str = None) -> Document:
386
+ """Detect (unless told) and parse. This is the library's main entry."""
387
+ v = variant or detect(data)['variant']
388
+ if v in ('ws4', 'ws5+'):
389
+ return parse_ws(data, encoding)
390
+ if v in ('printstream', 'text'):
391
+ return parse_printstream(data, encoding)
392
+ raise ValueError(f'not a convertible file (detected: {v})')
ctrlkd/emit.py ADDED
@@ -0,0 +1,236 @@
1
+ """ctrl-kd emitters: Document IR -> text / markdown / html / rtf.
2
+
3
+ Two rendering philosophies, chosen by the caller:
4
+ modern reflowed paragraphs, semantic markup (the IR already joined word
5
+ wraps and kept deliberate breaks — emitters just express it)
6
+ printed every line as laid out, fixed-width — how it came off the printer.
7
+ Print streams and columnar documents (WordStar ruler lines) force
8
+ this: their alignment only exists in a fixed-width world.
9
+ """
10
+ import html as _html
11
+
12
+ # ---------------------------------------------------------------- registry
13
+ #
14
+ # The extension point. An emitter is any callable (doc, mode='modern', **options)
15
+ # -> str, registered under a name. Two ways in:
16
+ #
17
+ # @ctrlkd.emitter('latex', ext='.tex') # in your own code
18
+ # def emit_latex(doc, mode='modern', **options): ...
19
+ #
20
+ # [project.entry-points."ctrlkd.emitters"] # in an installable plugin's
21
+ # docx = "ctrlkd_docx:emit_docx" # pyproject.toml
22
+ #
23
+ # Entry-point plugins are discovered at CLI startup; `pip install ctrl-kd-docx`
24
+ # is all a user needs. See EXTENDING.md for the IR contract and a worked example.
25
+
26
+ _REGISTRY = {} # name -> {'fn': callable, 'ext': '.xyz'}
27
+ _ALIASES = {'txt': 'text', 'md': 'markdown'}
28
+
29
+ def emitter(name, ext=None, aliases=()):
30
+ """Register an output format. Usable as a decorator."""
31
+ def deco(fn):
32
+ _REGISTRY[name] = {'fn': fn, 'ext': ext or '.' + name}
33
+ for a in aliases:
34
+ _ALIASES[a] = name
35
+ return fn
36
+ return deco
37
+
38
+ def get_emitter(name):
39
+ return _REGISTRY[_ALIASES.get(name, name)]
40
+
41
+ def formats():
42
+ """All registered format names (canonical + aliases), for CLI choices."""
43
+ return sorted(set(_REGISTRY) | set(_ALIASES))
44
+
45
+ def load_plugins():
46
+ """Discover third-party emitters via the 'ctrlkd.emitters' entry-point group."""
47
+ from importlib.metadata import entry_points
48
+ for ep in entry_points(group='ctrlkd.emitters'):
49
+ if ep.name not in _REGISTRY:
50
+ fn = ep.load()
51
+ _REGISTRY[ep.name] = {'fn': fn, 'ext': getattr(fn, 'ext', '.' + ep.name)}
52
+
53
+ def _printed(doc):
54
+ return doc.meta.get('variant') == 'printstream' or doc.meta.get('columnar')
55
+
56
+ # ---------------------------------------------------------------- text
57
+
58
+ def emit_text(doc, mode='modern', **_options):
59
+ out = []
60
+ for b in doc.blocks:
61
+ if b.kind == 'softpage': # WordStar's own pagination:
62
+ if mode == 'printed': # meaningful only line-for-line
63
+ out.append('\f')
64
+ continue
65
+ if b.kind == 'pagebreak':
66
+ out.append('\f' if mode == 'printed' else '\n' + '-' * 20 + '\n')
67
+ continue
68
+ para = '\n'.join(line.text() for line in b.lines)
69
+ if para.strip() or mode == 'printed':
70
+ out.append(para)
71
+ text = ('\n'.join(out) if mode == 'printed' or _printed(doc)
72
+ else '\n\n'.join(o for o in out if o.strip()))
73
+ if doc.footnotes:
74
+ text += '\n\n' + '\n'.join(f'[{i+1}] {"".join(s.text for s in n)}'
75
+ for i, n in enumerate(doc.footnotes))
76
+ return text + '\n'
77
+
78
+ # ---------------------------------------------------------------- markdown
79
+
80
+ _MD = {'b': '**', 'i': '*', 'strike': '~~'}
81
+ _MD_HTML = {'u': 'u', 'sup': 'sup', 'sub': 'sub'}
82
+
83
+ def _md_span(s):
84
+ text = s.text
85
+ if 'fnref' in s.styles:
86
+ return f'[^{text}]'
87
+ if not text.strip():
88
+ return text
89
+ esc = text.replace('\\', '\\\\')
90
+ for ch in '*_#`[]':
91
+ esc = esc.replace(ch, '\\' + ch)
92
+ lead = esc[:len(esc) - len(esc.lstrip())]
93
+ trail = esc[len(esc.rstrip()):]
94
+ core = esc.strip()
95
+ for st in s.styles:
96
+ if st in _MD:
97
+ core = f'{_MD[st]}{core}{_MD[st]}'
98
+ elif st in _MD_HTML:
99
+ t = _MD_HTML[st]
100
+ core = f'<{t}>{core}</{t}>'
101
+ return lead + core + trail
102
+
103
+ def emit_markdown(doc, mode='modern', **_options):
104
+ if mode == 'printed' or _printed(doc):
105
+ # alignment is the content: a fenced block is the honest representation
106
+ body = emit_text(doc, 'printed')
107
+ return '```\n' + body.rstrip('\n') + '\n```\n'
108
+ out = []
109
+ for b in doc.blocks:
110
+ if b.kind == 'softpage':
111
+ continue
112
+ if b.kind == 'pagebreak':
113
+ out.append('---')
114
+ continue
115
+ lines = [''.join(_md_span(s) for s in line.spans) for line in b.lines]
116
+ para = '\\\n'.join(l for l in lines) # hard breaks: trailing backslash
117
+ if b.heading and para.strip():
118
+ para = '#' * b.heading + ' ' + para.strip()
119
+ if para.strip():
120
+ out.append(para)
121
+ md = '\n\n'.join(out)
122
+ if doc.footnotes:
123
+ md += '\n\n' + '\n'.join(f'[^{i+1}]: {"".join(s.text for s in n)}'
124
+ for i, n in enumerate(doc.footnotes))
125
+ return md + '\n'
126
+
127
+ # ---------------------------------------------------------------- html
128
+
129
+ _CSS = """body{max-width:42rem;margin:2rem auto;padding:0 1rem;
130
+ font:17px/1.6 Georgia,serif;color:#222}p{margin:0 0 1em}
131
+ pre{font:14px/1.5 ui-monospace,Menlo,Consolas,monospace;overflow-x:auto}
132
+ hr.pb{border:none;border-top:1px dashed #bbb;margin:2rem 0}
133
+ @media(prefers-color-scheme:dark){body{background:#161616;color:#ddd}
134
+ hr.pb{border-top-color:#444}}"""
135
+
136
+ _TAG = {'b': 'strong', 'i': 'em', 'u': 'u', 'sup': 'sup', 'sub': 'sub', 'strike': 's'}
137
+
138
+ def _html_span(s, keep_ws=False):
139
+ text = _html.escape(s.text)
140
+ if keep_ws:
141
+ pass
142
+ elif text.startswith(' '): # typescript indent -> keep visible
143
+ n = len(text) - len(text.lstrip())
144
+ text = '&nbsp;' * n + text.lstrip()
145
+ for st in sorted(s.styles):
146
+ t = _TAG.get(st) # e.g. 'fnref' has no tag of its own
147
+ if t:
148
+ text = f'<{t}>{text}</{t}>'
149
+ return text
150
+
151
+ def emit_html(doc, mode='modern', title='', **_options):
152
+ parts = []
153
+ printed = mode == 'printed' or _printed(doc)
154
+ for b in doc.blocks:
155
+ if b.kind == 'softpage':
156
+ if printed:
157
+ parts.append('<hr class="pb">')
158
+ continue
159
+ if b.kind == 'pagebreak':
160
+ parts.append('<hr class="pb">')
161
+ continue
162
+ if b.heading:
163
+ txt = ' '.join(''.join(_html_span(s) for s in line.spans)
164
+ for line in b.lines).strip()
165
+ if txt:
166
+ parts.append(f'<h{b.heading}>{txt}</h{b.heading}>')
167
+ continue
168
+ if printed:
169
+ body = '\n'.join(''.join(_html_span(s, keep_ws=True) for s in line.spans)
170
+ for line in b.lines)
171
+ if body.strip():
172
+ parts.append(f'<pre>{body}</pre>')
173
+ else:
174
+ lines = [''.join(_html_span(s) for s in line.spans) for line in b.lines]
175
+ para = '<br>\n'.join(lines)
176
+ if para.strip():
177
+ parts.append(f'<p>{para}</p>')
178
+ if doc.footnotes:
179
+ notes = ''.join(f'<li>{"".join(_html.escape(s.text) for s in n)}</li>'
180
+ for n in doc.footnotes)
181
+ parts.append(f'<hr><ol class="footnotes">{notes}</ol>')
182
+ return ('<!doctype html><html><head><meta charset="utf-8">'
183
+ f'<meta name="viewport" content="width=device-width,initial-scale=1">'
184
+ f'<title>{_html.escape(title)}</title><style>{_CSS}</style></head>\n'
185
+ f'<body>\n' + '\n'.join(parts) + '\n</body></html>\n')
186
+
187
+ # ---------------------------------------------------------------- rtf
188
+
189
+ _RTF_ON = {'b': r'\b ', 'i': r'\i ', 'u': r'\ul ', 'sup': r'\super ',
190
+ 'sub': r'\sub ', 'strike': r'\strike '}
191
+
192
+ def _rtf_escape(text):
193
+ out = []
194
+ for ch in text:
195
+ if ch in '\\{}':
196
+ out.append('\\' + ch)
197
+ elif ord(ch) < 128:
198
+ out.append(ch)
199
+ else:
200
+ out.append(f'\\u{ord(ch)}?')
201
+ return ''.join(out)
202
+
203
+ def emit_rtf(doc, mode='modern', **_options):
204
+ printed = mode == 'printed' or _printed(doc)
205
+ font = r'\f1' if printed else r'\f0'
206
+ parts = []
207
+ for b in doc.blocks:
208
+ if b.kind == 'softpage':
209
+ if printed:
210
+ parts.append(r'\page ')
211
+ continue
212
+ if b.kind == 'pagebreak':
213
+ parts.append(r'\page ')
214
+ continue
215
+ lines = []
216
+ for line in b.lines:
217
+ seg = ''.join('{' + ''.join(_RTF_ON.get(s, '') for s in sorted(sp.styles))
218
+ + _rtf_escape(sp.text) + '}' for sp in line.spans)
219
+ lines.append(seg)
220
+ if b.heading:
221
+ lines = ['{' + r'\b\fs28 ' + l + '}' for l in lines]
222
+ joiner = r'\line ' if not printed else r'\line '
223
+ para = joiner.join(lines)
224
+ if para.strip() or printed:
225
+ parts.append(para + r'\par ')
226
+ if not printed:
227
+ parts.append(r'\par ') # blank line between paragraphs
228
+ body = '\n'.join(parts)
229
+ return (r'{\rtf1\ansi\deff0{\fonttbl{\f0 Times New Roman;}{\f1 Courier New;}}'
230
+ + '\n' + font + r'\fs24 ' + '\n' + body + '\n}\n')
231
+
232
+ # built-ins register through the same door plugins use
233
+ emitter('text', ext='.txt')(emit_text)
234
+ emitter('markdown', ext='.md')(emit_markdown)
235
+ emitter('html', ext='.html')(emit_html)
236
+ emitter('rtf', ext='.rtf')(emit_rtf)
ctrlkd/pdf.py ADDED
@@ -0,0 +1,200 @@
1
+ """ctrl-kd PDF emitter — the page as it would have printed.
2
+
3
+ Hand-written PDF 1.4, zero dependencies: the base-14 Courier family needs no font
4
+ embedding and its fixed metrics make layout exact. That fits the tool's soul — a
5
+ WordStar document rendered as the typescript it was, on Letter pages:
6
+
7
+ printed mode line-for-line, form feeds / .pa / WordStar's own page breaks
8
+ honored — a facsimile of the 1990 printout
9
+ modern mode reflowed paragraphs wrapped to the text column, headings bold,
10
+ footnotes at the end — still typewriter-set, still Courier
11
+
12
+ Styles: bold/italic map to the Courier variants, underline is drawn, superscript
13
+ is raised and reduced. Non-Latin-1 characters degrade to '?'.
14
+ """
15
+ import re as _re
16
+ from .emit import emitter, _printed
17
+
18
+ PAGE_W, PAGE_H = 612, 792 # US Letter, points
19
+ MARGIN = 72 # 1 inch
20
+ SIZE, LEAD = 12, 12 # 10 CPI pica x 6 LPI — the dot-matrix standard;
21
+ # a 65-col WordStar line is exactly 6.5in
22
+ TOP_MODERN, TOP_PRINTED = 72, 36 # print streams carry their own top-margin blanks
23
+ LINES_MODERN = (PAGE_H - 2 * 72) // LEAD # 54
24
+ LINES_PRINTED = (PAGE_H - 2 * 36) // LEAD # 60
25
+ MAX_COLS = int((PAGE_W - 2 * MARGIN) / (SIZE * 0.6)) # 65 — WordStar's own margin
26
+
27
+ FONTS = {(False, False): 'F1', (True, False): 'F2',
28
+ (False, True): 'F3', (True, True): 'F4'}
29
+ FONT_NAMES = {'F1': 'Courier', 'F2': 'Courier-Bold',
30
+ 'F3': 'Courier-Oblique', 'F4': 'Courier-BoldOblique'}
31
+
32
+ def _esc(text):
33
+ raw = text.encode('latin-1', 'replace')
34
+ return raw.replace(b'\\', b'\\\\').replace(b'(', b'\\(').replace(b')', b'\\)')
35
+
36
+ def _wrap_line(spans, width):
37
+ """Wrap one IR line's spans to `width` columns, preserving styles.
38
+ Returns a list of segment-lines: [[(text, styles), ...], ...]."""
39
+ tokens = [] # words and space-runs, styled
40
+ for text, styles in spans:
41
+ for piece in _re.split(r'( +)', text):
42
+ if piece:
43
+ tokens.append((piece, styles))
44
+ lines, line, col = [], [], 0
45
+ for text, styles in tokens:
46
+ if not text.isspace() and col and col + len(text) > width:
47
+ while line and line[-1][0].isspace(): # no trailing spaces
48
+ col -= len(line.pop()[0])
49
+ lines.append(line); line, col = [], 0
50
+ line.append((text, styles)); col += len(text)
51
+ while line and line[-1][0].isspace():
52
+ line.pop()
53
+ if line or not lines:
54
+ lines.append(line)
55
+ return lines
56
+
57
+ def _doc_to_pagelines(doc, printed):
58
+ """IR -> list of pages, each a list of segment-lines."""
59
+ lines = [] # None = forced page break
60
+ for b in doc.blocks:
61
+ if b.kind == 'pagebreak' or (b.kind == 'softpage' and printed):
62
+ lines.append(None)
63
+ continue
64
+ if b.kind == 'softpage':
65
+ continue
66
+ for line in b.lines:
67
+ spans = [(s.text, s.styles) for s in line.spans]
68
+ if printed:
69
+ lines.append(spans) # verbatim, no wrap
70
+ else:
71
+ lines.extend(_wrap_line(spans, MAX_COLS))
72
+ if not printed and b.lines:
73
+ lines.append([]) # blank line between paragraphs
74
+ if doc.footnotes:
75
+ lines += [[], [('-' * 20, frozenset())], []]
76
+ for i, n in enumerate(doc.footnotes):
77
+ note = f'[{i + 1}] ' + ''.join(s.text for s in n)
78
+ lines.extend(_wrap_line([(note, frozenset())], MAX_COLS))
79
+ cap = LINES_PRINTED if printed else LINES_MODERN
80
+ pages, page = [], []
81
+ for l in lines:
82
+ if l is None or len(page) >= cap:
83
+ if page or l is None:
84
+ pages.append(page); page = []
85
+ if l is None:
86
+ continue
87
+ page.append(l)
88
+ if page:
89
+ pages.append(page)
90
+ # We supply the paper margins, so WordStar's own margin blanks in a print
91
+ # stream would double up. But deliberate spacing (a chapter-drop on page 1)
92
+ # must survive: the MACHINE margin is uniform on every page, so strip only
93
+ # the minimum leading-blank count seen on pages 2+ — anything beyond it on
94
+ # any page is the author's layout. Trailing blanks are always machine.
95
+ def leading(pg):
96
+ n = 0
97
+ while n < len(pg) and not any(t.strip() for t, _ in pg[n]):
98
+ n += 1
99
+ return n
100
+ if printed and pages:
101
+ machine = min(leading(pg) for pg in pages[1:]) if len(pages) > 1 \
102
+ else leading(pages[0])
103
+ for pg in pages:
104
+ del pg[:min(machine, leading(pg))]
105
+ while pg and not any(t.strip() for t, _ in pg[-1]):
106
+ pg.pop()
107
+ else:
108
+ for pg in pages:
109
+ del pg[:leading(pg)]
110
+ while pg and not any(t.strip() for t, _ in pg[-1]):
111
+ pg.pop()
112
+ return pages or [[]]
113
+
114
+ def _coalesce(line):
115
+ """Merge adjacent same-style segments into single text runs."""
116
+ out = []
117
+ for text, styles in line:
118
+ if out and out[-1][1] == styles:
119
+ out[-1][0] += text
120
+ else:
121
+ out.append([text, styles])
122
+ return out
123
+
124
+ def _page_stream(pagelines, top):
125
+ ops = []
126
+ y = PAGE_H - top - SIZE
127
+ for line in pagelines:
128
+ x = MARGIN
129
+ for text, styles in _coalesce(line):
130
+ if not text:
131
+ continue
132
+ sup = 'sup' in styles or 'sub' in styles
133
+ size = 8 if sup else SIZE
134
+ rise = 3 if 'sup' in styles else (-2 if 'sub' in styles else 0)
135
+ font = FONTS[('b' in styles, 'i' in styles)]
136
+ ops.append(b'BT /%s %d Tf %d Ts %.1f %.1f Td (%s) Tj ET' %
137
+ (font.encode(), size, rise, x, y, _esc(text)))
138
+ w = len(text) * size * 0.6
139
+ if 'u' in styles and text.strip():
140
+ ops.append(b'0.6 w %.1f %.1f m %.1f %.1f l S' % (x, y - 1.5, x + w, y - 1.5))
141
+ if 'strike' in styles and text.strip():
142
+ ops.append(b'0.6 w %.1f %.1f m %.1f %.1f l S' % (x, y + 3, x + w, y + 3))
143
+ x += w
144
+ y -= LEAD
145
+ return b'\n'.join(ops)
146
+
147
+ @emitter('pdf')
148
+ def emit_pdf(doc, mode='modern', **options):
149
+ """Assemble the PDF: catalog, page tree, four Courier fonts, one content
150
+ stream per page, xref. Returns bytes — PDF is a binary format."""
151
+ printed = mode == 'printed' or _printed(doc)
152
+ pages = _doc_to_pagelines(doc, printed)
153
+ top = TOP_PRINTED if printed else TOP_MODERN
154
+ objs = [] # (obj_number, bytes)
155
+
156
+ n_pages = len(pages)
157
+ font_objs = {} # F1..F4 -> obj num
158
+ next_num = 3
159
+ for f in ('F1', 'F2', 'F3', 'F4'):
160
+ font_objs[f] = next_num
161
+ objs.append((next_num,
162
+ b'<< /Type /Font /Subtype /Type1 /BaseFont /%s >>'
163
+ % FONT_NAMES[f].encode()))
164
+ next_num += 1
165
+ font_dict = b' '.join(b'/%s %d 0 R' % (f.encode(), n) for f, n in font_objs.items())
166
+
167
+ page_nums, content_nums = [], []
168
+ for _ in range(n_pages):
169
+ page_nums.append(next_num); next_num += 1
170
+ content_nums.append(next_num); next_num += 1
171
+
172
+ kids = b' '.join(b'%d 0 R' % n for n in page_nums)
173
+ objs.insert(0, (1, b'<< /Type /Catalog /Pages 2 0 R >>'))
174
+ objs.insert(1, (2, b'<< /Type /Pages /Kids [%s] /Count %d >>' % (kids, n_pages)))
175
+
176
+ for pnum, cnum, pl in zip(page_nums, content_nums, pages):
177
+ objs.append((pnum,
178
+ b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] '
179
+ b'/Resources << /Font << %s >> >> /Contents %d 0 R >>'
180
+ % (PAGE_W, PAGE_H, font_dict, cnum)))
181
+ stream = _page_stream(pl, top)
182
+ objs.append((cnum, b'<< /Length %d >>\nstream\n%s\nendstream'
183
+ % (len(stream), stream)))
184
+
185
+ objs.sort()
186
+ out = bytearray(b'%PDF-1.4\n')
187
+ offsets = {}
188
+ for num, body in objs:
189
+ offsets[num] = len(out)
190
+ out += b'%d 0 obj\n%s\nendobj\n' % (num, body)
191
+ xref_at = len(out)
192
+ count = max(offsets) + 1
193
+ out += b'xref\n0 %d\n0000000000 65535 f \n' % count
194
+ for n in range(1, count):
195
+ out += b'%010d 00000 n \n' % offsets[n]
196
+ out += (b'trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n'
197
+ % (count, xref_at))
198
+ return bytes(out)
199
+
200
+ emit_pdf.ext = '.pdf'