ctrl-kd 1.3.0__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctrl_kd-1.3.0/src/ctrl_kd.egg-info → ctrl_kd-2.0.0}/PKG-INFO +8 -5
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/README.md +7 -4
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/pyproject.toml +1 -1
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0/src/ctrl_kd.egg-info}/PKG-INFO +8 -5
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrlkd/__init__.py +3 -2
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrlkd/core.py +67 -8
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrlkd/emit.py +16 -5
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrlkd/pdf.py +50 -8
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/tests/test_ctrlkd.py +93 -1
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/LICENSE +0 -0
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/setup.cfg +0 -0
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/SOURCES.txt +0 -0
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/dependency_links.txt +0 -0
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/entry_points.txt +0 -0
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/top_level.txt +0 -0
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrlkd/cli.py +0 -0
- {ctrl_kd-1.3.0 → ctrl_kd-2.0.0}/src/ctrlkd/convert.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctrl-kd
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Convert WordStar 4-7 documents and print-to-disk files to text, Markdown, HTML, RTF, or PDF. ^KD: save and done.
|
|
5
5
|
Author: Jon Michaels
|
|
6
6
|
License: MIT
|
|
@@ -77,10 +77,13 @@ against surviving period printouts of the same documents. Its rules are empirica
|
|
|
77
77
|
Paragraph styles become headings, and 82/86 convert with zero mojibake.
|
|
78
78
|
More WS5–7 corpora still welcome.
|
|
79
79
|
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb`/`.hm`/`.fm`/
|
|
80
|
-
`.lh`/`.ls`, so `--mode printed`
|
|
81
|
-
|
|
82
|
-
defaults, not a guessed 1-inch margin
|
|
83
|
-
|
|
80
|
+
`.lh`/`.ls`/`.cw`, so `--mode printed` reproduces WordStar's own page: the
|
|
81
|
+
vertical model (`.pl − .mt − .mb` at the `.lh` line height — 55 text lines
|
|
82
|
+
for WordStar's defaults, not a guessed 1-inch margin), the horizontal one
|
|
83
|
+
(`.po` page offset at the `.cw` character pitch), and WordStar's own line
|
|
84
|
+
breaks — a soft return is where the line broke on paper, so printed output
|
|
85
|
+
keeps it (and reflowed Modern output still joins it). `--diagnose` says
|
|
86
|
+
whether each figure came from the file or from the default.
|
|
84
87
|
In `printed` mode footnotes are laid out the way WordStar laid them out: at
|
|
85
88
|
the foot of the page that references them, behind a twenty-dash separator,
|
|
86
89
|
split across pages with `...Continued...` when they do not fit.
|
|
@@ -58,10 +58,13 @@ against surviving period printouts of the same documents. Its rules are empirica
|
|
|
58
58
|
Paragraph styles become headings, and 82/86 convert with zero mojibake.
|
|
59
59
|
More WS5–7 corpora still welcome.
|
|
60
60
|
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb`/`.hm`/`.fm`/
|
|
61
|
-
`.lh`/`.ls`, so `--mode printed`
|
|
62
|
-
|
|
63
|
-
defaults, not a guessed 1-inch margin
|
|
64
|
-
|
|
61
|
+
`.lh`/`.ls`/`.cw`, so `--mode printed` reproduces WordStar's own page: the
|
|
62
|
+
vertical model (`.pl − .mt − .mb` at the `.lh` line height — 55 text lines
|
|
63
|
+
for WordStar's defaults, not a guessed 1-inch margin), the horizontal one
|
|
64
|
+
(`.po` page offset at the `.cw` character pitch), and WordStar's own line
|
|
65
|
+
breaks — a soft return is where the line broke on paper, so printed output
|
|
66
|
+
keeps it (and reflowed Modern output still joins it). `--diagnose` says
|
|
67
|
+
whether each figure came from the file or from the default.
|
|
65
68
|
In `printed` mode footnotes are laid out the way WordStar laid them out: at
|
|
66
69
|
the foot of the page that references them, behind a twenty-dash separator,
|
|
67
70
|
split across pages with `...Continued...` when they do not fit.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ctrl-kd"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0.0"
|
|
8
8
|
description = "Convert WordStar 4-7 documents and print-to-disk files to text, Markdown, HTML, RTF, or PDF. ^KD: save and done."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctrl-kd
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Convert WordStar 4-7 documents and print-to-disk files to text, Markdown, HTML, RTF, or PDF. ^KD: save and done.
|
|
5
5
|
Author: Jon Michaels
|
|
6
6
|
License: MIT
|
|
@@ -77,10 +77,13 @@ against surviving period printouts of the same documents. Its rules are empirica
|
|
|
77
77
|
Paragraph styles become headings, and 82/86 convert with zero mojibake.
|
|
78
78
|
More WS5–7 corpora still welcome.
|
|
79
79
|
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb`/`.hm`/`.fm`/
|
|
80
|
-
`.lh`/`.ls`, so `--mode printed`
|
|
81
|
-
|
|
82
|
-
defaults, not a guessed 1-inch margin
|
|
83
|
-
|
|
80
|
+
`.lh`/`.ls`/`.cw`, so `--mode printed` reproduces WordStar's own page: the
|
|
81
|
+
vertical model (`.pl − .mt − .mb` at the `.lh` line height — 55 text lines
|
|
82
|
+
for WordStar's defaults, not a guessed 1-inch margin), the horizontal one
|
|
83
|
+
(`.po` page offset at the `.cw` character pitch), and WordStar's own line
|
|
84
|
+
breaks — a soft return is where the line broke on paper, so printed output
|
|
85
|
+
keeps it (and reflowed Modern output still joins it). `--diagnose` says
|
|
86
|
+
whether each figure came from the file or from the default.
|
|
84
87
|
In `printed` mode footnotes are laid out the way WordStar laid them out: at
|
|
85
88
|
the foot of the page that references them, behind a twenty-dash separator,
|
|
86
89
|
split across pages with `...Continued...` when they do not fit.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""ctrl-kd — convert WordStar-era files to modern formats. ^KD: save and done."""
|
|
2
|
-
from .core import detect, parse, parse_ws, parse_printstream,
|
|
2
|
+
from .core import (detect, parse, parse_ws, parse_printstream, merged_lines,
|
|
3
|
+
Document, Block, Line, Span)
|
|
3
4
|
from .emit import (emit_text, emit_markdown, emit_html, emit_rtf,
|
|
4
5
|
emitter, get_emitter, formats, load_plugins)
|
|
5
6
|
from .pdf import emit_pdf # registers the 'pdf' format
|
|
6
7
|
from .convert import convert, select_notes, DEFAULT_NOTE_KINDS, ALL_NOTE_KINDS
|
|
7
8
|
|
|
8
|
-
__version__ = '
|
|
9
|
+
__version__ = '2.0.0'
|
|
@@ -30,6 +30,9 @@ class Span:
|
|
|
30
30
|
@dataclass
|
|
31
31
|
class Line:
|
|
32
32
|
spans: list = field(default_factory=list)
|
|
33
|
+
soft: bool = False # ends in WordStar's own word wrap (8D soft
|
|
34
|
+
# return): ON PAPER this was a real line break;
|
|
35
|
+
# for reflow it joins the next line (merged_lines)
|
|
33
36
|
|
|
34
37
|
def text(self):
|
|
35
38
|
return ''.join(s.text for s in self.spans)
|
|
@@ -40,6 +43,36 @@ class Block:
|
|
|
40
43
|
lines: list = field(default_factory=list)
|
|
41
44
|
heading: int = 0 # 0 = body text; 1-3 = WS5+ title/header/subheading
|
|
42
45
|
|
|
46
|
+
def merged_lines(block: Block) -> list:
|
|
47
|
+
"""Block.lines with soft-wrapped runs joined back into logical lines --
|
|
48
|
+
what Block.lines itself WAS before 2.0.0 stored physical lines.
|
|
49
|
+
|
|
50
|
+
Printed mode renders Block.lines directly: a soft return is where
|
|
51
|
+
WordStar broke the line on paper, so the physical line IS the printed
|
|
52
|
+
line (merging them was the bug that printed thousand-column lines).
|
|
53
|
+
Reflowing consumers (every Modern emitter) call this instead: a soft
|
|
54
|
+
break is just word wrap, so the continuation belongs to the same logical
|
|
55
|
+
line. The join rule is the one parse_ws itself used when it merged at
|
|
56
|
+
parse time -- a space in the wrapped line's trailing style, suppressed
|
|
57
|
+
after an existing space or a hyphenated break -- so Modern output is
|
|
58
|
+
byte-identical either side of the 2.0.0 split."""
|
|
59
|
+
out, cur = [], None
|
|
60
|
+
for line in block.lines:
|
|
61
|
+
if cur is None:
|
|
62
|
+
cur = Line(list(line.spans))
|
|
63
|
+
else:
|
|
64
|
+
cur.spans.extend(line.spans)
|
|
65
|
+
if line.soft:
|
|
66
|
+
t = cur.spans[-1].text if cur.spans else ''
|
|
67
|
+
if t and not t.endswith((' ', '-')):
|
|
68
|
+
cur.spans.append(Span(' ', cur.spans[-1].styles))
|
|
69
|
+
continue
|
|
70
|
+
out.append(cur)
|
|
71
|
+
cur = None
|
|
72
|
+
if cur is not None:
|
|
73
|
+
out.append(cur)
|
|
74
|
+
return out
|
|
75
|
+
|
|
43
76
|
@dataclass
|
|
44
77
|
class Note:
|
|
45
78
|
"""One footnote/endnote/annotation/comment: WordStar 7.0 symmetrical
|
|
@@ -242,7 +275,7 @@ _DOT_NUM_RE = re.compile(rb'^\s*([0-9]*\.?[0-9]+)\s*("|[A-Za-z]{1,2})?')
|
|
|
242
275
|
_PAGE_DOT_KEYS = {b'PL': 'pl_lines', b'MT': 'mt_lines',
|
|
243
276
|
b'MB': 'mb_lines', b'PO': 'po_cols',
|
|
244
277
|
b'HM': 'hm_lines', b'FM': 'fm_lines',
|
|
245
|
-
b'LH': 'lh_48', b'LS': 'ls'}
|
|
278
|
+
b'LH': 'lh_48', b'LS': 'ls', b'CW': 'cw_120'}
|
|
246
279
|
|
|
247
280
|
# Named page sizes at 6 LPI (WordStar 7.0 file format spec: ".PL ... assuming
|
|
248
281
|
# 6 lines per inch. An eleven inch page contains 66 lines."): 66 lines/11in
|
|
@@ -260,13 +293,18 @@ PAGE_SIZE_SNAP_IN = 0.25
|
|
|
260
293
|
DEFAULT_PL_LINES = 66.0 # WordStar's own default: 66 lines = 11in = US Letter
|
|
261
294
|
DEFAULT_MT_LINES = 3.0 # spec: ".MT ... Default value is 3 lines."
|
|
262
295
|
DEFAULT_MB_LINES = 8.0 # spec: ".MB ... The default value is 8 lines."
|
|
263
|
-
DEFAULT_PO_COLS =
|
|
264
|
-
#
|
|
265
|
-
#
|
|
296
|
+
DEFAULT_PO_COLS = 8.0 # WS7 manual, "Page Layout": "The default page offset
|
|
297
|
+
# is .8 inch" -- 8 print columns at the default 10 CPI.
|
|
298
|
+
# (Through 1.3.0 this was 0, "least presumptuous", from
|
|
299
|
+
# the file-format spec stating none; the manual DOES
|
|
300
|
+
# state one, and 2.0.0 actually renders the offset, so
|
|
301
|
+
# the manual's figure governs.)
|
|
266
302
|
DEFAULT_HM_LINES = 2.0 # spec: ".HM ... Default is 2." (header sits INSIDE .mt)
|
|
267
303
|
DEFAULT_FM_LINES = 2.0 # spec: ".FM ... Default is 2." (footer sits INSIDE .mb)
|
|
268
304
|
DEFAULT_LH_48 = 8.0 # spec: ".LH ... The default is 8/48 or 6 lines per inch."
|
|
269
305
|
DEFAULT_LS = 1.0 # single spacing (WS7 manual, "Line Spacing")
|
|
306
|
+
DEFAULT_CW_120 = 12.0 # spec: ".CW ... The default is 12 (12/120ths is 10
|
|
307
|
+
# characters per inch)."
|
|
270
308
|
|
|
271
309
|
def _dot_arg_inches(value: float, unit: bytes | None):
|
|
272
310
|
"""Convert a dot-command argument's optional unit suffix to inches.
|
|
@@ -313,8 +351,18 @@ def _resolve_ls_arg(value: float, unit: bytes | None):
|
|
|
313
351
|
return None
|
|
314
352
|
return value
|
|
315
353
|
|
|
354
|
+
def _resolve_cw_arg(value: float, unit: bytes | None):
|
|
355
|
+
""".cw argument -> character width in 1/120in units. Unit-less IS 120ths
|
|
356
|
+
(spec: ".CW ... the width of the characters in 1/120 inch increments. ...
|
|
357
|
+
The default is 12 (12/120ths is 10 characters per inch)"); an explicit
|
|
358
|
+
unit suffix converts. Non-positive width is meaningless: rejected."""
|
|
359
|
+
inches = _dot_arg_inches(value, unit)
|
|
360
|
+
resolved = value if inches is None else inches * 120.0
|
|
361
|
+
return resolved if resolved > 0 else None
|
|
362
|
+
|
|
316
363
|
_PAGE_DOT_RESOLVERS = {'po_cols': _resolve_cols_arg, 'lh_48': _resolve_lh_arg,
|
|
317
|
-
'ls': _resolve_ls_arg
|
|
364
|
+
'ls': _resolve_ls_arg,
|
|
365
|
+
'cw_120': _resolve_cw_arg} # everything else: lines at 6 LPI
|
|
318
366
|
|
|
319
367
|
def _text_lines_per_page(pl_lines: float, mt_lines: float, mb_lines: float,
|
|
320
368
|
lh_48: float) -> int:
|
|
@@ -717,9 +765,17 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
|
|
|
717
765
|
for s in spans:
|
|
718
766
|
cur_line.spans.append(s)
|
|
719
767
|
if sep == 'wrap':
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
768
|
+
# A soft return: a REAL line break on paper (printed mode renders
|
|
769
|
+
# it), just word wrap for reflow (merged_lines joins it back with
|
|
770
|
+
# the space rule that used to live right here). 2.0.0: physical
|
|
771
|
+
# lines are stored; merging is the consumer's choice now.
|
|
772
|
+
if cur_line.spans:
|
|
773
|
+
cur_line.soft = True
|
|
774
|
+
close_line()
|
|
775
|
+
elif cur.lines:
|
|
776
|
+
cur.lines[-1].soft = True # invisible (toggles-only) line:
|
|
777
|
+
# its softness binds the previous
|
|
778
|
+
# printed line, as the old merge did
|
|
723
779
|
elif sep == 'line':
|
|
724
780
|
close_line()
|
|
725
781
|
else: # para / eof
|
|
@@ -740,6 +796,7 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
|
|
|
740
796
|
fm_lines = page.get('fm_lines')
|
|
741
797
|
lh_48 = page.get('lh_48')
|
|
742
798
|
ls = page.get('ls')
|
|
799
|
+
cw_120 = page.get('cw_120')
|
|
743
800
|
# Exposed per the IR contract: a consumer must be able to distinguish
|
|
744
801
|
# "Legal (from file)" from "Letter (default)" -- provenance lives
|
|
745
802
|
# alongside every resolved figure, not just the page size.
|
|
@@ -762,6 +819,8 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
|
|
|
762
819
|
'lh_source': 'file' if lh_48 is not None else 'default',
|
|
763
820
|
'ls': ls if ls is not None else DEFAULT_LS,
|
|
764
821
|
'ls_source': 'file' if ls is not None else 'default',
|
|
822
|
+
'cw_120': cw_120 if cw_120 is not None else DEFAULT_CW_120,
|
|
823
|
+
'cw_source': 'file' if cw_120 is not None else 'default',
|
|
765
824
|
}
|
|
766
825
|
# The one derived figure consumers actually need: printed text lines per
|
|
767
826
|
# page, from WordStar's own vertical model (see _text_lines_per_page for
|
|
@@ -10,6 +10,8 @@ Two rendering philosophies, chosen by the caller:
|
|
|
10
10
|
import html as _html
|
|
11
11
|
import re
|
|
12
12
|
|
|
13
|
+
from .core import merged_lines
|
|
14
|
+
|
|
13
15
|
# ---------------------------------------------------------------- registry
|
|
14
16
|
#
|
|
15
17
|
# The extension point. An emitter is any callable (doc, mode='modern', **options)
|
|
@@ -164,6 +166,7 @@ def emit_text(doc, mode='modern', notes=DEFAULT_NOTE_KINDS, **_options):
|
|
|
164
166
|
keep = frozenset(notes)
|
|
165
167
|
pairs = _annotated_notes(doc)
|
|
166
168
|
refs = _ref_pairs(pairs)
|
|
169
|
+
printed = mode == 'printed' or _printed(doc)
|
|
167
170
|
out = []
|
|
168
171
|
for b in doc.blocks:
|
|
169
172
|
if b.kind == 'softpage': # WordStar's own pagination:
|
|
@@ -174,7 +177,9 @@ def emit_text(doc, mode='modern', notes=DEFAULT_NOTE_KINDS, **_options):
|
|
|
174
177
|
out.append('\f' if mode == 'printed' else '\n' + '-' * 20 + '\n')
|
|
175
178
|
continue
|
|
176
179
|
lines = []
|
|
177
|
-
|
|
180
|
+
# printed: PHYSICAL lines (soft returns broke the line on paper);
|
|
181
|
+
# modern: logical lines, soft runs joined back (core.merged_lines)
|
|
182
|
+
for line in (b.lines if printed else merged_lines(b)):
|
|
178
183
|
seg = []
|
|
179
184
|
for s in line.spans:
|
|
180
185
|
note, label = (_resolve_ref(refs, s.text)
|
|
@@ -275,7 +280,8 @@ def emit_markdown(doc, mode='modern', notes=DEFAULT_NOTE_KINDS, **_options):
|
|
|
275
280
|
if b.kind == 'pagebreak':
|
|
276
281
|
out.append('---')
|
|
277
282
|
continue
|
|
278
|
-
lines = [''.join(_md_span(s, refs, keep) for s in line.spans)
|
|
283
|
+
lines = [''.join(_md_span(s, refs, keep) for s in line.spans)
|
|
284
|
+
for line in merged_lines(b)] # logical lines: soft wraps joined
|
|
279
285
|
para = '\\\n'.join(l for l in lines) # hard breaks: trailing backslash
|
|
280
286
|
if b.heading and para.strip():
|
|
281
287
|
para = '#' * b.heading + ' ' + para.strip()
|
|
@@ -417,16 +423,19 @@ def emit_html(doc, mode='modern', title='', notes=DEFAULT_NOTE_KINDS, **_options
|
|
|
417
423
|
parts.append('<hr class="pb">')
|
|
418
424
|
continue
|
|
419
425
|
if b.heading:
|
|
420
|
-
|
|
426
|
+
# merged either mode: a heading is a logical unit, and joining its
|
|
427
|
+
# logical lines with a space is what this always rendered
|
|
428
|
+
txt = ' '.join(_html_line(line, refs, keep) for line in merged_lines(b)).strip()
|
|
421
429
|
if txt:
|
|
422
430
|
parts.append(f'<h{b.heading}>{txt}</h{b.heading}>')
|
|
423
431
|
continue
|
|
424
432
|
if printed:
|
|
433
|
+
# PHYSICAL lines: inside <pre>, a soft return is a real line break
|
|
425
434
|
body = '\n'.join(_html_line(line, refs, keep, keep_ws=True) for line in b.lines)
|
|
426
435
|
if body.strip():
|
|
427
436
|
parts.append(f'<pre>{body}</pre>')
|
|
428
437
|
else:
|
|
429
|
-
lines = [_html_line(line, refs, keep) for line in b
|
|
438
|
+
lines = [_html_line(line, refs, keep) for line in merged_lines(b)]
|
|
430
439
|
para = '<br>\n'.join(lines)
|
|
431
440
|
if para.strip():
|
|
432
441
|
parts.append(f'<p>{para}</p>')
|
|
@@ -540,7 +549,9 @@ def emit_rtf(doc, mode='modern', notes=DEFAULT_NOTE_KINDS, **_options):
|
|
|
540
549
|
parts.append(r'\page ')
|
|
541
550
|
continue
|
|
542
551
|
lines = []
|
|
543
|
-
|
|
552
|
+
# printed: physical lines (\line at every printed break, soft or hard);
|
|
553
|
+
# modern: logical lines only
|
|
554
|
+
for line in (b.lines if printed else merged_lines(b)):
|
|
544
555
|
seg = ''.join(_rtf_span(sp, refs, keep) for sp in line.spans)
|
|
545
556
|
lines.append(seg)
|
|
546
557
|
if b.heading:
|
|
@@ -13,6 +13,7 @@ Styles: bold/italic map to the Courier variants, underline is drawn, superscript
|
|
|
13
13
|
is raised and reduced. Non-Latin-1 characters degrade to '?'.
|
|
14
14
|
"""
|
|
15
15
|
import re as _re
|
|
16
|
+
from .core import merged_lines as _merged_lines
|
|
16
17
|
from .emit import emitter, _printed, _annotated_notes, _ref_pairs
|
|
17
18
|
|
|
18
19
|
PAGE_W, PAGE_H = 612, 792 # US Letter, points
|
|
@@ -97,6 +98,37 @@ def _printed_lead(doc):
|
|
|
97
98
|
lh = page.get('lh_48', 8.0)
|
|
98
99
|
return lh * 1.5 if lh > 0 else LEAD
|
|
99
100
|
|
|
101
|
+
def _printed_size(doc):
|
|
102
|
+
"""Type size in points for printed mode, from .cw: character width in
|
|
103
|
+
1/120in units, and Courier advances 0.6em, so a pitch of cw/120in per
|
|
104
|
+
character IS a (cw*72/120)/0.6 = cw*1.0 point font. The default .cw 12
|
|
105
|
+
(10 CPI pica) IS the 12pt this emitter always used; .cw 10 is 12 CPI
|
|
106
|
+
elite at 10pt. Rounded to whole points (the Tf operator is written as
|
|
107
|
+
an integer, as it always has been), floored at 1. Print streams keep
|
|
108
|
+
the fixed SIZE."""
|
|
109
|
+
page = doc.meta.get('page')
|
|
110
|
+
if page is None:
|
|
111
|
+
return SIZE
|
|
112
|
+
cw = page.get('cw_120', 12.0)
|
|
113
|
+
return max(1, round(cw)) if cw > 0 else SIZE
|
|
114
|
+
|
|
115
|
+
def _printed_left(doc, size):
|
|
116
|
+
"""Left edge of text in points for printed mode, from .po: "the number
|
|
117
|
+
of print columns from the left edge of the paper to the left margin of
|
|
118
|
+
text. The current setting of character width (.CW) determines the
|
|
119
|
+
actual amount of indentation" -- so the offset is po columns at this
|
|
120
|
+
document's own advance (0.6em of `size`). The default .po 8 (the WS7
|
|
121
|
+
manual's ".8 inch" at 10 CPI) lands at 57.6pt -- NOT the old fixed 72pt
|
|
122
|
+
MARGIN, which was this emitter's guess, not WordStar's. Print streams
|
|
123
|
+
keep MARGIN: their offset spaces, where a driver emitted them, are
|
|
124
|
+
in-band. Clamped inside the page for garbage .po from misdetected
|
|
125
|
+
binaries."""
|
|
126
|
+
page = doc.meta.get('page')
|
|
127
|
+
if page is None:
|
|
128
|
+
return float(MARGIN)
|
|
129
|
+
left = page.get('po_cols', 8.0) * size * 0.6
|
|
130
|
+
return max(0.0, min(left, PAGE_W - size * 0.6))
|
|
131
|
+
|
|
100
132
|
FONTS = {(False, False): 'F1', (True, False): 'F2',
|
|
101
133
|
(False, True): 'F3', (True, True): 'F4'}
|
|
102
134
|
FONT_NAMES = {'F1': 'Courier', 'F2': 'Courier-Bold',
|
|
@@ -384,7 +416,10 @@ def _doc_to_pagelines(doc, printed):
|
|
|
384
416
|
continue
|
|
385
417
|
if b.kind == 'softpage':
|
|
386
418
|
continue
|
|
387
|
-
|
|
419
|
+
# printed renders PHYSICAL lines (a soft return broke the line on
|
|
420
|
+
# paper); modern reflows LOGICAL lines (soft runs joined back --
|
|
421
|
+
# core.merged_lines, the 2.0.0 split)
|
|
422
|
+
for line in (b.lines if printed else _merged_lines(b)):
|
|
388
423
|
# the docstring's "headings bold" promise: heading blocks render in
|
|
389
424
|
# Courier-Bold (found unimplemented by the Swift port, job-011)
|
|
390
425
|
spans = [(s.text, s.styles | {'b'} if b.heading else s.styles)
|
|
@@ -454,21 +489,24 @@ def _coalesce(line):
|
|
|
454
489
|
out.append([text, styles])
|
|
455
490
|
return out
|
|
456
491
|
|
|
457
|
-
def _page_stream(pagelines, top, page_h=PAGE_H, lead=LEAD
|
|
492
|
+
def _page_stream(pagelines, top, page_h=PAGE_H, lead=LEAD, size=SIZE,
|
|
493
|
+
left=float(MARGIN)):
|
|
458
494
|
ops = []
|
|
459
|
-
|
|
495
|
+
sup_size = max(1, round(size * 2 / 3)) # 8 at the default 12 -- the
|
|
496
|
+
# ratio this emitter always used
|
|
497
|
+
y = page_h - top - size
|
|
460
498
|
for line in pagelines:
|
|
461
|
-
x =
|
|
499
|
+
x = left
|
|
462
500
|
for text, styles in _coalesce(line):
|
|
463
501
|
if not text:
|
|
464
502
|
continue
|
|
465
503
|
sup = 'sup' in styles or 'sub' in styles
|
|
466
|
-
|
|
504
|
+
size_here = sup_size if sup else size
|
|
467
505
|
rise = 3 if 'sup' in styles else (-2 if 'sub' in styles else 0)
|
|
468
506
|
font = FONTS[('b' in styles, 'i' in styles)]
|
|
469
507
|
ops.append(b'BT /%s %d Tf %d Ts %.1f %.1f Td (%s) Tj ET' %
|
|
470
|
-
(font.encode(),
|
|
471
|
-
w = len(text) *
|
|
508
|
+
(font.encode(), size_here, rise, x, y, _esc(text)))
|
|
509
|
+
w = len(text) * size_here * 0.6
|
|
472
510
|
if 'u' in styles and text.strip():
|
|
473
511
|
ops.append(b'0.6 w %.1f %.1f m %.1f %.1f l S' % (x, y - 1.5, x + w, y - 1.5))
|
|
474
512
|
if 'strike' in styles and text.strip():
|
|
@@ -486,6 +524,10 @@ def emit_pdf(doc, mode='modern', **options):
|
|
|
486
524
|
top = _printed_top(doc) if printed else TOP_MODERN # .mt-derived for WS docs;
|
|
487
525
|
# default .mt 3 IS the old 36pt
|
|
488
526
|
lead = _printed_lead(doc) if printed else LEAD # .lh-derived; .lh 8 IS 12pt
|
|
527
|
+
size = _printed_size(doc) if printed else SIZE # .cw-derived; .cw 12 IS 12pt
|
|
528
|
+
left = _printed_left(doc, size) if printed else float(MARGIN) # .po-derived;
|
|
529
|
+
# default .po 8 = 57.6pt, the
|
|
530
|
+
# manual's .8in -- see _printed_left
|
|
489
531
|
page_h = _resolved_page_height(doc, printed) # file geometry wins in
|
|
490
532
|
# printed mode (Task: .pl);
|
|
491
533
|
# modern stays fixed Letter
|
|
@@ -516,7 +558,7 @@ def emit_pdf(doc, mode='modern', **options):
|
|
|
516
558
|
b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] '
|
|
517
559
|
b'/Resources << /Font << %s >> >> /Contents %d 0 R >>'
|
|
518
560
|
% (PAGE_W, page_h, font_dict, cnum)))
|
|
519
|
-
stream = _page_stream(pl, top, page_h, lead)
|
|
561
|
+
stream = _page_stream(pl, top, page_h, lead, size, left)
|
|
520
562
|
objs.append((cnum, b'<< /Length %d >>\nstream\n%s\nendstream'
|
|
521
563
|
% (len(stream), stream)))
|
|
522
564
|
|
|
@@ -718,7 +718,9 @@ def test_page_geometry_defaults_to_letter():
|
|
|
718
718
|
assert page['size_source'] == 'default'
|
|
719
719
|
assert page['mt_lines'] == 3.0 and page['mt_source'] == 'default'
|
|
720
720
|
assert page['mb_lines'] == 8.0 and page['mb_source'] == 'default'
|
|
721
|
-
|
|
721
|
+
# 8, not 0: WS7 manual, "The default page offset is .8 inch" -- since 2.0.0
|
|
722
|
+
# renders the offset, the manual's stated default governs
|
|
723
|
+
assert page['po_cols'] == 8.0 and page['po_source'] == 'default'
|
|
722
724
|
|
|
723
725
|
def test_page_geometry_pl_unitless_is_lines_not_inches():
|
|
724
726
|
# THE trap: WordTsar's own @todo admits it falls back to inches when
|
|
@@ -878,6 +880,96 @@ def test_pdf_output_bytes_carry_mt_top_and_lh_lead():
|
|
|
878
880
|
assert ys[0] - ys[1] == 24.0 # lead from .lh, not fixed 12
|
|
879
881
|
assert ys[1] - ys[2] == 24.0
|
|
880
882
|
|
|
883
|
+
# ------------------------------------------- horizontal geometry (2.0.0)
|
|
884
|
+
|
|
885
|
+
# WS4-shaped bytes: soft return = 8D 0A, hard = 0D 0A. ws4_text-style helper
|
|
886
|
+
# fixtures exist above for style codes; plain ASCII is enough here.
|
|
887
|
+
SOFT = b'\x8d\x0a'
|
|
888
|
+
|
|
889
|
+
def _ws_wrapped_para():
|
|
890
|
+
# two soft-wrapped physical lines then a hard return -- classic word wrap.
|
|
891
|
+
# Lines are near the 65-col default margin so lines_pass reads the soft
|
|
892
|
+
# breaks as wrap (joining would overflow), not as deliberate breaks.
|
|
893
|
+
l1 = b'w' * 30 + b' ' + b'x' * 30
|
|
894
|
+
l2 = b'y' * 30 + b' ' + b'z' * 30
|
|
895
|
+
return l1 + SOFT + l2 + HARD
|
|
896
|
+
|
|
897
|
+
def test_soft_wrapped_lines_stay_physical_in_the_ir():
|
|
898
|
+
doc = core.parse_ws(_ws_wrapped_para())
|
|
899
|
+
para = [b for b in doc.blocks if b.kind == 'para'][0]
|
|
900
|
+
assert len(para.lines) == 2 # physical lines preserved
|
|
901
|
+
assert para.lines[0].soft is True # ...and marked
|
|
902
|
+
assert para.lines[1].soft is False
|
|
903
|
+
|
|
904
|
+
def test_merged_lines_reproduces_the_old_logical_line():
|
|
905
|
+
# the reflow view: soft runs joined with the old space rule
|
|
906
|
+
doc = core.parse_ws(_ws_wrapped_para())
|
|
907
|
+
para = [b for b in doc.blocks if b.kind == 'para'][0]
|
|
908
|
+
logical = core.merged_lines(para)
|
|
909
|
+
assert len(logical) == 1
|
|
910
|
+
text = logical[0].text()
|
|
911
|
+
assert text == 'w' * 30 + ' ' + 'x' * 30 + ' ' + 'y' * 30 + ' ' + 'z' * 30
|
|
912
|
+
|
|
913
|
+
def test_merged_lines_suppresses_space_after_hyphen():
|
|
914
|
+
# l1 must be long enough that lines_pass reads the soft break as wrap
|
|
915
|
+
# (L + 1 + W >= the 65-col default margin), or the break is 'line' and
|
|
916
|
+
# never merges at all
|
|
917
|
+
l1 = b'a' * 56 + b' hyphen-'
|
|
918
|
+
data = l1 + SOFT + b'ated word plus enough text to reach the margin here.' + HARD
|
|
919
|
+
doc = core.parse_ws(data)
|
|
920
|
+
para = [b for b in doc.blocks if b.kind == 'para'][0]
|
|
921
|
+
logical = core.merged_lines(para)
|
|
922
|
+
assert 'hyphen-ated' in logical[0].text() # no space injected
|
|
923
|
+
|
|
924
|
+
def test_printed_text_renders_physical_lines_modern_renders_logical():
|
|
925
|
+
# emit_text on a parse_ws doc directly: convert()'s auto-detect would
|
|
926
|
+
# read these low-high-bit synthetic bytes as a printstream and never
|
|
927
|
+
# exercise the soft flags at all (a vacuous pass)
|
|
928
|
+
from ctrlkd.emit import emit_text
|
|
929
|
+
doc = core.parse_ws(_ws_wrapped_para())
|
|
930
|
+
printed = emit_text(doc, mode='printed')
|
|
931
|
+
modern = emit_text(doc, mode='modern')
|
|
932
|
+
assert 'w' * 30 + ' ' + 'x' * 30 + '\n' + 'y' * 30 in printed # break kept
|
|
933
|
+
assert 'x' * 30 + ' ' + 'y' * 30 in modern # joined
|
|
934
|
+
|
|
935
|
+
def test_page_geometry_cw_parsed_and_units():
|
|
936
|
+
doc = core.parse_ws(b'.CW 10' + HARD + b'x' + HARD)
|
|
937
|
+
assert doc.meta['page']['cw_120'] == 10.0
|
|
938
|
+
assert doc.meta['page']['cw_source'] == 'file'
|
|
939
|
+
# 0.1 inch = 12/120ths -- the default pitch, stated in inches
|
|
940
|
+
doc = core.parse_ws(b'.CW 0.1"' + HARD + b'x' + HARD)
|
|
941
|
+
assert doc.meta['page']['cw_120'] == pytest.approx(12.0)
|
|
942
|
+
|
|
943
|
+
def test_page_geometry_cw_zero_rejected():
|
|
944
|
+
doc = core.parse_ws(b'.CW 0' + HARD + b'x' + HARD)
|
|
945
|
+
assert doc.meta['page']['cw_120'] == 12.0
|
|
946
|
+
assert doc.meta['page']['cw_source'] == 'default'
|
|
947
|
+
|
|
948
|
+
def test_pdf_printed_size_and_left_follow_cw_po():
|
|
949
|
+
from ctrlkd.pdf import _printed_size, _printed_left
|
|
950
|
+
d_default = core.parse_ws(b'x' + HARD)
|
|
951
|
+
assert _printed_size(d_default) == 12
|
|
952
|
+
assert _printed_left(d_default, 12) == pytest.approx(8 * 12 * 0.6) # 57.6
|
|
953
|
+
d_elite = core.parse_ws(b'.CW 10' + HARD + b'.PO 12' + HARD + b'x' + HARD)
|
|
954
|
+
assert _printed_size(d_elite) == 10
|
|
955
|
+
assert _printed_left(d_elite, 10) == pytest.approx(12 * 10 * 0.6) # 72.0
|
|
956
|
+
|
|
957
|
+
def test_pdf_output_bytes_carry_po_left_and_cw_size():
|
|
958
|
+
# end-to-end: x-coordinates and Tf size come from the file's own .po/.cw
|
|
959
|
+
import re
|
|
960
|
+
from ctrlkd.pdf import emit_pdf
|
|
961
|
+
data = (b'.PO 12' + HARD + b'.CW 10' + HARD + b'Line one.' + HARD)
|
|
962
|
+
pdf = emit_pdf(core.parse_ws(data), mode='printed')
|
|
963
|
+
m = re.search(rb'/F1 (\d+) Tf \d+ Ts ([\d.]+) [\d.]+ Td', pdf)
|
|
964
|
+
assert m and m.group(1) == b'10' # elite type size
|
|
965
|
+
assert m.group(2) == b'72.0' # 12 cols x 10pt x 0.6em
|
|
966
|
+
|
|
967
|
+
def test_pdf_printstream_keeps_fixed_margin_and_size():
|
|
968
|
+
from ctrlkd.pdf import _printed_size, _printed_left
|
|
969
|
+
ps = core.parse_printstream(b'line one\r\n')
|
|
970
|
+
assert _printed_size(ps) == 12
|
|
971
|
+
assert _printed_left(ps, 12) == 72.0 # streams: offset is in-band
|
|
972
|
+
|
|
881
973
|
def test_pdf_printstream_capacity_is_the_full_page():
|
|
882
974
|
# a print stream IS the printed page -- its margin blanks travel in-band,
|
|
883
975
|
# so the page budget is the FULL 66 lines of a Letter page: anything
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|