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.
- ctrl_kd-1.1.2.dist-info/METADATA +120 -0
- ctrl_kd-1.1.2.dist-info/RECORD +11 -0
- ctrl_kd-1.1.2.dist-info/WHEEL +5 -0
- ctrl_kd-1.1.2.dist-info/entry_points.txt +2 -0
- ctrl_kd-1.1.2.dist-info/licenses/LICENSE +21 -0
- ctrl_kd-1.1.2.dist-info/top_level.txt +1 -0
- ctrlkd/__init__.py +12 -0
- ctrlkd/cli.py +90 -0
- ctrlkd/core.py +392 -0
- ctrlkd/emit.py +236 -0
- ctrlkd/pdf.py +200 -0
|
@@ -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,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 = ' ' * 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'
|