ctrl-kd 1.2.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.2.0/src/ctrl_kd.egg-info → ctrl_kd-2.0.0}/PKG-INFO +9 -4
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/README.md +8 -3
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/pyproject.toml +1 -1
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0/src/ctrl_kd.egg-info}/PKG-INFO +9 -4
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrlkd/__init__.py +3 -2
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrlkd/core.py +147 -9
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrlkd/emit.py +16 -5
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrlkd/pdf.py +99 -17
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/tests/test_ctrlkd.py +207 -2
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/LICENSE +0 -0
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/setup.cfg +0 -0
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/SOURCES.txt +0 -0
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/dependency_links.txt +0 -0
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/entry_points.txt +0 -0
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrl_kd.egg-info/top_level.txt +0 -0
- {ctrl_kd-1.2.0 → ctrl_kd-2.0.0}/src/ctrlkd/cli.py +0 -0
- {ctrl_kd-1.2.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
|
|
@@ -76,9 +76,14 @@ against surviving period printouts of the same documents. Its rules are empirica
|
|
|
76
76
|
WordStar never printed them; `--diagnose` still reports that they exist.
|
|
77
77
|
Paragraph styles become headings, and 82/86 convert with zero mojibake.
|
|
78
78
|
More WS5–7 corpora still welcome.
|
|
79
|
-
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb
|
|
80
|
-
printed`
|
|
81
|
-
|
|
79
|
+
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb`/`.hm`/`.fm`/
|
|
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.
|
|
82
87
|
In `printed` mode footnotes are laid out the way WordStar laid them out: at
|
|
83
88
|
the foot of the page that references them, behind a twenty-dash separator,
|
|
84
89
|
split across pages with `...Continued...` when they do not fit.
|
|
@@ -57,9 +57,14 @@ against surviving period printouts of the same documents. Its rules are empirica
|
|
|
57
57
|
WordStar never printed them; `--diagnose` still reports that they exist.
|
|
58
58
|
Paragraph styles become headings, and 82/86 convert with zero mojibake.
|
|
59
59
|
More WS5–7 corpora still welcome.
|
|
60
|
-
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb
|
|
61
|
-
printed`
|
|
62
|
-
|
|
60
|
+
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb`/`.hm`/`.fm`/
|
|
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.
|
|
63
68
|
In `printed` mode footnotes are laid out the way WordStar laid them out: at
|
|
64
69
|
the foot of the page that references them, behind a twenty-dash separator,
|
|
65
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
|
|
@@ -76,9 +76,14 @@ against surviving period printouts of the same documents. Its rules are empirica
|
|
|
76
76
|
WordStar never printed them; `--diagnose` still reports that they exist.
|
|
77
77
|
Paragraph styles become headings, and 82/86 convert with zero mojibake.
|
|
78
78
|
More WS5–7 corpora still welcome.
|
|
79
|
-
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb
|
|
80
|
-
printed`
|
|
81
|
-
|
|
79
|
+
* **Page geometry** from the file's own `.pl`/`.po`/`.mt`/`.mb`/`.hm`/`.fm`/
|
|
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.
|
|
82
87
|
In `printed` mode footnotes are laid out the way WordStar laid them out: at
|
|
83
88
|
the foot of the page that references them, behind a twenty-dash separator,
|
|
84
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'
|
|
@@ -16,6 +16,7 @@ WordStar background this code encodes:
|
|
|
16
16
|
sent to the printer, captured to a file. They ARE the printed page.
|
|
17
17
|
"""
|
|
18
18
|
from __future__ import annotations
|
|
19
|
+
import math
|
|
19
20
|
import re
|
|
20
21
|
from dataclasses import dataclass, field
|
|
21
22
|
|
|
@@ -29,6 +30,9 @@ class Span:
|
|
|
29
30
|
@dataclass
|
|
30
31
|
class Line:
|
|
31
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)
|
|
32
36
|
|
|
33
37
|
def text(self):
|
|
34
38
|
return ''.join(s.text for s in self.spans)
|
|
@@ -39,6 +43,36 @@ class Block:
|
|
|
39
43
|
lines: list = field(default_factory=list)
|
|
40
44
|
heading: int = 0 # 0 = body text; 1-3 = WS5+ title/header/subheading
|
|
41
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
|
+
|
|
42
76
|
@dataclass
|
|
43
77
|
class Note:
|
|
44
78
|
"""One footnote/endnote/annotation/comment: WordStar 7.0 symmetrical
|
|
@@ -239,7 +273,9 @@ _DOT_CMD_RE = re.compile(rb'^\.([A-Za-z]{1,3})\s*(.*)$')
|
|
|
239
273
|
_DOT_NUM_RE = re.compile(rb'^\s*([0-9]*\.?[0-9]+)\s*("|[A-Za-z]{1,2})?')
|
|
240
274
|
|
|
241
275
|
_PAGE_DOT_KEYS = {b'PL': 'pl_lines', b'MT': 'mt_lines',
|
|
242
|
-
b'MB': 'mb_lines', b'PO': 'po_cols'
|
|
276
|
+
b'MB': 'mb_lines', b'PO': 'po_cols',
|
|
277
|
+
b'HM': 'hm_lines', b'FM': 'fm_lines',
|
|
278
|
+
b'LH': 'lh_48', b'LS': 'ls', b'CW': 'cw_120'}
|
|
243
279
|
|
|
244
280
|
# Named page sizes at 6 LPI (WordStar 7.0 file format spec: ".PL ... assuming
|
|
245
281
|
# 6 lines per inch. An eleven inch page contains 66 lines."): 66 lines/11in
|
|
@@ -257,9 +293,18 @@ PAGE_SIZE_SNAP_IN = 0.25
|
|
|
257
293
|
DEFAULT_PL_LINES = 66.0 # WordStar's own default: 66 lines = 11in = US Letter
|
|
258
294
|
DEFAULT_MT_LINES = 3.0 # spec: ".MT ... Default value is 3 lines."
|
|
259
295
|
DEFAULT_MB_LINES = 8.0 # spec: ".MB ... The default value is 8 lines."
|
|
260
|
-
DEFAULT_PO_COLS =
|
|
261
|
-
#
|
|
262
|
-
#
|
|
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.)
|
|
302
|
+
DEFAULT_HM_LINES = 2.0 # spec: ".HM ... Default is 2." (header sits INSIDE .mt)
|
|
303
|
+
DEFAULT_FM_LINES = 2.0 # spec: ".FM ... Default is 2." (footer sits INSIDE .mb)
|
|
304
|
+
DEFAULT_LH_48 = 8.0 # spec: ".LH ... The default is 8/48 or 6 lines per inch."
|
|
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)."
|
|
263
308
|
|
|
264
309
|
def _dot_arg_inches(value: float, unit: bytes | None):
|
|
265
310
|
"""Convert a dot-command argument's optional unit suffix to inches.
|
|
@@ -287,6 +332,67 @@ def _resolve_cols_arg(value: float, unit: bytes | None) -> float:
|
|
|
287
332
|
inches = _dot_arg_inches(value, unit)
|
|
288
333
|
return value if inches is None else inches * 10.0
|
|
289
334
|
|
|
335
|
+
def _resolve_lh_arg(value: float, unit: bytes | None):
|
|
336
|
+
""".lh argument -> line height in 1/48in units. Unit-less IS 48ths (WS7
|
|
337
|
+
manual: "You can also type the dot command in 48ths of an inch. For
|
|
338
|
+
example, .lh 8 is 8/48 inch, or the standard 6 lines per inch"); an
|
|
339
|
+
explicit unit suffix converts. `.lh a` (auto-leading) never reaches here
|
|
340
|
+
-- the numeric matcher won't match it, so it stays default + verbatim.
|
|
341
|
+
A non-positive height is meaningless: rejected (None), default stands."""
|
|
342
|
+
inches = _dot_arg_inches(value, unit)
|
|
343
|
+
resolved = value if inches is None else inches * 48.0
|
|
344
|
+
return resolved if resolved > 0 else None
|
|
345
|
+
|
|
346
|
+
def _resolve_ls_arg(value: float, unit: bytes | None):
|
|
347
|
+
""".ls argument -> line spacing. "A line spacing of between 1 and 9"
|
|
348
|
+
(WS7 file format spec); anything else is junk, rejected (None). Any unit
|
|
349
|
+
suffix is likewise junk -- spacing is a count, not a measure."""
|
|
350
|
+
if unit is not None or not 1 <= value <= 9:
|
|
351
|
+
return None
|
|
352
|
+
return value
|
|
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
|
+
|
|
363
|
+
_PAGE_DOT_RESOLVERS = {'po_cols': _resolve_cols_arg, 'lh_48': _resolve_lh_arg,
|
|
364
|
+
'ls': _resolve_ls_arg,
|
|
365
|
+
'cw_120': _resolve_cw_arg} # everything else: lines at 6 LPI
|
|
366
|
+
|
|
367
|
+
def _text_lines_per_page(pl_lines: float, mt_lines: float, mb_lines: float,
|
|
368
|
+
lh_48: float) -> int:
|
|
369
|
+
"""Printed text lines per page -- WordStar's own vertical model (WS7
|
|
370
|
+
manual, "Page Layout"): "The top and bottom margins define the space
|
|
371
|
+
between the text and the top and bottom of the paper. On an 8.5 x 11-inch
|
|
372
|
+
page, if the top margin is .33 inches and the bottom margin is 1.33
|
|
373
|
+
inches, the space left for text is 9.33 inches." Lines available is that
|
|
374
|
+
text height divided by the line height (.lh, 1/48in units): "Changing the
|
|
375
|
+
line height affects the number of lines that can be printed on a page."
|
|
376
|
+
WordStar's own defaults (.pl 66 .mt 3 .mb 8 .lh 8) give 55.
|
|
377
|
+
|
|
378
|
+
Deliberately NOT in the formula:
|
|
379
|
+
- .hm/.fm -- the header prints WITHIN .mt and the footer WITHIN .mb
|
|
380
|
+
(".MT ... The header is printed within this margin"; ".MB ... The
|
|
381
|
+
footer or page number is printed within this margin"), so they position
|
|
382
|
+
header/footer inside space already subtracted, never reserve more.
|
|
383
|
+
- .ls -- line-spacing blanks are literal lines in the file ("when you use
|
|
384
|
+
line spacing, the blank lines become part of the file", WS7 manual,
|
|
385
|
+
"Line Spacing"), so the body text already carries them; dividing
|
|
386
|
+
capacity by .ls would double-count.
|
|
387
|
+
|
|
388
|
+
Unit-less .mt/.mb are lines at the fixed 6 LPI baseline (the module-note
|
|
389
|
+
assumption); .lh at parse time is resolved once per document (first
|
|
390
|
+
occurrence wins), not tracked per-line."""
|
|
391
|
+
usable = pl_lines - mt_lines - mb_lines # lines at 6 LPI
|
|
392
|
+
if not math.isfinite(usable) or not math.isfinite(lh_48) or lh_48 <= 0:
|
|
393
|
+
return 1
|
|
394
|
+
return max(1, int(usable * 8.0 / lh_48))
|
|
395
|
+
|
|
290
396
|
def _resolve_page_size(pl_lines: float):
|
|
291
397
|
"""pl_lines -> (height_in, size_name). Snaps to a named size when close;
|
|
292
398
|
otherwise reports the raw geometry under 'Custom' rather than forcing a
|
|
@@ -340,8 +446,10 @@ def _parse_page_dot(cmd: bytes, page: dict, meta_extra: dict):
|
|
|
340
446
|
return
|
|
341
447
|
value = float(num.group(1))
|
|
342
448
|
unit = num.group(2)
|
|
343
|
-
resolver =
|
|
344
|
-
|
|
449
|
+
resolver = _PAGE_DOT_RESOLVERS.get(key, _resolve_lines_arg)
|
|
450
|
+
resolved = resolver(value, unit)
|
|
451
|
+
if resolved is not None: # junk argument: default stands
|
|
452
|
+
page[key] = resolved
|
|
345
453
|
elif name in (b'PT', b'PSA', b'PSB'):
|
|
346
454
|
# WordTsar's own invented dot commands (its source calls them "not a
|
|
347
455
|
# Wordstar command"). A real WordStar file never contains these --
|
|
@@ -657,9 +765,17 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
|
|
|
657
765
|
for s in spans:
|
|
658
766
|
cur_line.spans.append(s)
|
|
659
767
|
if sep == 'wrap':
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
663
779
|
elif sep == 'line':
|
|
664
780
|
close_line()
|
|
665
781
|
else: # para / eof
|
|
@@ -676,6 +792,11 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
|
|
|
676
792
|
mt_lines = page.get('mt_lines')
|
|
677
793
|
mb_lines = page.get('mb_lines')
|
|
678
794
|
po_cols = page.get('po_cols')
|
|
795
|
+
hm_lines = page.get('hm_lines')
|
|
796
|
+
fm_lines = page.get('fm_lines')
|
|
797
|
+
lh_48 = page.get('lh_48')
|
|
798
|
+
ls = page.get('ls')
|
|
799
|
+
cw_120 = page.get('cw_120')
|
|
679
800
|
# Exposed per the IR contract: a consumer must be able to distinguish
|
|
680
801
|
# "Legal (from file)" from "Letter (default)" -- provenance lives
|
|
681
802
|
# alongside every resolved figure, not just the page size.
|
|
@@ -690,7 +811,24 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
|
|
|
690
811
|
'mb_source': 'file' if mb_lines is not None else 'default',
|
|
691
812
|
'po_cols': po_cols if po_cols is not None else DEFAULT_PO_COLS,
|
|
692
813
|
'po_source': 'file' if po_cols is not None else 'default',
|
|
814
|
+
'hm_lines': hm_lines if hm_lines is not None else DEFAULT_HM_LINES,
|
|
815
|
+
'hm_source': 'file' if hm_lines is not None else 'default',
|
|
816
|
+
'fm_lines': fm_lines if fm_lines is not None else DEFAULT_FM_LINES,
|
|
817
|
+
'fm_source': 'file' if fm_lines is not None else 'default',
|
|
818
|
+
'lh_48': lh_48 if lh_48 is not None else DEFAULT_LH_48,
|
|
819
|
+
'lh_source': 'file' if lh_48 is not None else 'default',
|
|
820
|
+
'ls': ls if ls is not None else DEFAULT_LS,
|
|
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',
|
|
693
824
|
}
|
|
825
|
+
# The one derived figure consumers actually need: printed text lines per
|
|
826
|
+
# page, from WordStar's own vertical model (see _text_lines_per_page for
|
|
827
|
+
# the formula and the deliberate exclusions). Defaults -> 55, NOT the 60
|
|
828
|
+
# a naive 1in-margin Letter computation gives.
|
|
829
|
+
doc.meta['page']['text_lines'] = _text_lines_per_page(
|
|
830
|
+
doc.meta['page']['pl_lines'], doc.meta['page']['mt_lines'],
|
|
831
|
+
doc.meta['page']['mb_lines'], doc.meta['page']['lh_48'])
|
|
694
832
|
if meta_extra:
|
|
695
833
|
doc.meta.update(meta_extra)
|
|
696
834
|
return doc
|
|
@@ -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,18 +13,21 @@ 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
|
|
19
20
|
MARGIN = 72 # 1 inch
|
|
20
21
|
SIZE, LEAD = 12, 12 # 10 CPI pica x 6 LPI — the dot-matrix standard;
|
|
21
22
|
# a 65-col WordStar line is exactly 6.5in
|
|
22
|
-
TOP_MODERN, TOP_PRINTED = 72, 36 #
|
|
23
|
+
TOP_MODERN, TOP_PRINTED = 72, 36 # printed: default when a stream has no geometry
|
|
24
|
+
# meta (its margin blanks travel in-band); WS docs
|
|
25
|
+
# get an .mt-derived top from _printed_top()
|
|
23
26
|
LINES_MODERN = (PAGE_H - 2 * 72) // LEAD # 54
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
# Printed capacity is per-document: _printed_cap() -- WordStar's own model
|
|
28
|
+
# (.pl - .mt - .mb at the .lh line height; 55 for WordStar's defaults). The
|
|
29
|
+
# old hardcoded (PAGE_H - 2*36)//LEAD = 60 was a naive Letter computation
|
|
30
|
+
# that matched no WordStar the manual describes.
|
|
28
31
|
MAX_COLS = int((PAGE_W - 2 * MARGIN) / (SIZE * 0.6)) # 65 — WordStar's own margin
|
|
29
32
|
|
|
30
33
|
# Period-authentic footnote layout (Printed mode only -- WordStar Professional
|
|
@@ -55,9 +58,76 @@ def _resolved_page_height(doc, printed):
|
|
|
55
58
|
def _printed_cap(doc):
|
|
56
59
|
"""Lines of vertical room on a printed page for THIS document -- the
|
|
57
60
|
cap used both for plain pagination and as the footnote layout's page
|
|
58
|
-
budget (see _paginate_printed_notes).
|
|
61
|
+
budget (see _paginate_printed_notes).
|
|
62
|
+
|
|
63
|
+
WS documents carry doc.meta['page'] and get WordStar's own vertical
|
|
64
|
+
model (core._text_lines_per_page: .pl - .mt - .mb at the .lh line
|
|
65
|
+
height -- 55 for WordStar's defaults, NOT the 60 a naive 1in-margin
|
|
66
|
+
computation gives). Print streams have no 'page' meta and ARE the
|
|
67
|
+
printed page -- their margin blanks travel in-band -- so their budget
|
|
68
|
+
is the FULL page height in lines (66 on Letter): anything smaller
|
|
69
|
+
would split a physical page that the printer produced whole."""
|
|
70
|
+
page = doc.meta.get('page')
|
|
71
|
+
if page is not None:
|
|
72
|
+
return max(FOOTNOTE_FLOOR + 1, page.get('text_lines', 55))
|
|
59
73
|
page_h = _resolved_page_height(doc, True)
|
|
60
|
-
return max(FOOTNOTE_FLOOR + 1, (page_h
|
|
74
|
+
return max(FOOTNOTE_FLOOR + 1, int(page_h // LEAD))
|
|
75
|
+
|
|
76
|
+
def _printed_top(doc):
|
|
77
|
+
"""Top-of-text offset in points for printed mode. WS documents start
|
|
78
|
+
where .mt says (lines at 6 LPI -> 12pt each; the default .mt 3 is the
|
|
79
|
+
36pt this emitter always used). Print streams keep the fixed 36pt --
|
|
80
|
+
their own top-margin blanks are in the data (minus the machine-margin
|
|
81
|
+
strip in _doc_to_pagelines). Clamped inside the page so garbage .mt
|
|
82
|
+
from a misdetected binary degrades to an ugly page, never an absurd
|
|
83
|
+
coordinate space."""
|
|
84
|
+
page = doc.meta.get('page')
|
|
85
|
+
if page is None:
|
|
86
|
+
return TOP_PRINTED
|
|
87
|
+
page_h = _resolved_page_height(doc, True)
|
|
88
|
+
return max(0, min(round(page.get('mt_lines', 3.0) * 12), page_h - LEAD))
|
|
89
|
+
|
|
90
|
+
def _printed_lead(doc):
|
|
91
|
+
"""Baseline-to-baseline distance in points for printed mode: .lh is
|
|
92
|
+
1/48in units, a point is 1/72in -> lh * 1.5. Default .lh 8 IS the 12pt
|
|
93
|
+
lead this emitter always used. Print streams (no 'page' meta) keep the
|
|
94
|
+
fixed LEAD."""
|
|
95
|
+
page = doc.meta.get('page')
|
|
96
|
+
if page is None:
|
|
97
|
+
return LEAD
|
|
98
|
+
lh = page.get('lh_48', 8.0)
|
|
99
|
+
return lh * 1.5 if lh > 0 else LEAD
|
|
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))
|
|
61
131
|
|
|
62
132
|
FONTS = {(False, False): 'F1', (True, False): 'F2',
|
|
63
133
|
(False, True): 'F3', (True, True): 'F4'}
|
|
@@ -346,7 +416,10 @@ def _doc_to_pagelines(doc, printed):
|
|
|
346
416
|
continue
|
|
347
417
|
if b.kind == 'softpage':
|
|
348
418
|
continue
|
|
349
|
-
|
|
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)):
|
|
350
423
|
# the docstring's "headings bold" promise: heading blocks render in
|
|
351
424
|
# Courier-Bold (found unimplemented by the Swift port, job-011)
|
|
352
425
|
spans = [(s.text, s.styles | {'b'} if b.heading else s.styles)
|
|
@@ -416,27 +489,30 @@ def _coalesce(line):
|
|
|
416
489
|
out.append([text, styles])
|
|
417
490
|
return out
|
|
418
491
|
|
|
419
|
-
def _page_stream(pagelines, top, page_h=PAGE_H
|
|
492
|
+
def _page_stream(pagelines, top, page_h=PAGE_H, lead=LEAD, size=SIZE,
|
|
493
|
+
left=float(MARGIN)):
|
|
420
494
|
ops = []
|
|
421
|
-
|
|
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
|
|
422
498
|
for line in pagelines:
|
|
423
|
-
x =
|
|
499
|
+
x = left
|
|
424
500
|
for text, styles in _coalesce(line):
|
|
425
501
|
if not text:
|
|
426
502
|
continue
|
|
427
503
|
sup = 'sup' in styles or 'sub' in styles
|
|
428
|
-
|
|
504
|
+
size_here = sup_size if sup else size
|
|
429
505
|
rise = 3 if 'sup' in styles else (-2 if 'sub' in styles else 0)
|
|
430
506
|
font = FONTS[('b' in styles, 'i' in styles)]
|
|
431
507
|
ops.append(b'BT /%s %d Tf %d Ts %.1f %.1f Td (%s) Tj ET' %
|
|
432
|
-
(font.encode(),
|
|
433
|
-
w = len(text) *
|
|
508
|
+
(font.encode(), size_here, rise, x, y, _esc(text)))
|
|
509
|
+
w = len(text) * size_here * 0.6
|
|
434
510
|
if 'u' in styles and text.strip():
|
|
435
511
|
ops.append(b'0.6 w %.1f %.1f m %.1f %.1f l S' % (x, y - 1.5, x + w, y - 1.5))
|
|
436
512
|
if 'strike' in styles and text.strip():
|
|
437
513
|
ops.append(b'0.6 w %.1f %.1f m %.1f %.1f l S' % (x, y + 3, x + w, y + 3))
|
|
438
514
|
x += w
|
|
439
|
-
y -=
|
|
515
|
+
y -= lead
|
|
440
516
|
return b'\n'.join(ops)
|
|
441
517
|
|
|
442
518
|
@emitter('pdf')
|
|
@@ -445,7 +521,13 @@ def emit_pdf(doc, mode='modern', **options):
|
|
|
445
521
|
stream per page, xref. Returns bytes — PDF is a binary format."""
|
|
446
522
|
printed = mode == 'printed' or _printed(doc)
|
|
447
523
|
pages = _doc_to_pagelines(doc, printed)
|
|
448
|
-
top =
|
|
524
|
+
top = _printed_top(doc) if printed else TOP_MODERN # .mt-derived for WS docs;
|
|
525
|
+
# default .mt 3 IS the old 36pt
|
|
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
|
|
449
531
|
page_h = _resolved_page_height(doc, printed) # file geometry wins in
|
|
450
532
|
# printed mode (Task: .pl);
|
|
451
533
|
# modern stays fixed Letter
|
|
@@ -476,7 +558,7 @@ def emit_pdf(doc, mode='modern', **options):
|
|
|
476
558
|
b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] '
|
|
477
559
|
b'/Resources << /Font << %s >> >> /Contents %d 0 R >>'
|
|
478
560
|
% (PAGE_W, page_h, font_dict, cnum)))
|
|
479
|
-
stream = _page_stream(pl, top, page_h)
|
|
561
|
+
stream = _page_stream(pl, top, page_h, lead, size, left)
|
|
480
562
|
objs.append((cnum, b'<< /Length %d >>\nstream\n%s\nendstream'
|
|
481
563
|
% (len(stream), stream)))
|
|
482
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
|
|
@@ -778,6 +780,205 @@ def test_page_geometry_malformed_pl_does_not_crash():
|
|
|
778
780
|
assert doc.meta['page']['pl_lines'] == 66.0
|
|
779
781
|
assert doc.meta['page']['size_source'] == 'default'
|
|
780
782
|
|
|
783
|
+
# ------------------------------------------- the vertical model (text_lines)
|
|
784
|
+
|
|
785
|
+
def test_page_geometry_defaults_give_wordstar_55_not_60():
|
|
786
|
+
# THE fix this section exists for: WordStar's own defaults (.pl 66
|
|
787
|
+
# .mt 3 .mb 8 .lh 8) put 55 text lines on a page -- the manual's model,
|
|
788
|
+
# (pl - mt - mb) at 6 LPI -- not the 60 a naive 1in-margin Letter
|
|
789
|
+
# computation produced for every document before 1.3.0.
|
|
790
|
+
from ctrlkd.pdf import _printed_cap
|
|
791
|
+
doc = core.parse_ws(b'Body text.' + HARD)
|
|
792
|
+
assert doc.meta['page']['text_lines'] == 55
|
|
793
|
+
assert _printed_cap(doc) == 55
|
|
794
|
+
|
|
795
|
+
def test_page_geometry_mt_mb_change_capacity():
|
|
796
|
+
doc = core.parse_ws(b'.MT 6' + HARD + b'.MB 6' + HARD + b'x' + HARD)
|
|
797
|
+
assert doc.meta['page']['text_lines'] == 54 # 66 - 6 - 6
|
|
798
|
+
|
|
799
|
+
def test_page_geometry_lh_parsed_and_scales_capacity():
|
|
800
|
+
# .lh is 1/48in units: .lh 16 doubles the line height, halving capacity
|
|
801
|
+
# (the manual: "Changing the line height affects the number of lines
|
|
802
|
+
# that can be printed on a page"). floor(55 * 8 / 16) = 27.
|
|
803
|
+
doc = core.parse_ws(b'.LH 16' + HARD + b'x' + HARD)
|
|
804
|
+
page = doc.meta['page']
|
|
805
|
+
assert page['lh_48'] == 16.0 and page['lh_source'] == 'file'
|
|
806
|
+
assert page['text_lines'] == 27
|
|
807
|
+
|
|
808
|
+
def test_page_geometry_lh_unit_suffix_converts():
|
|
809
|
+
# .lh 12p = 12/72in = 8/48in -> the standard height, stated in points
|
|
810
|
+
doc = core.parse_ws(b'.LH 12P' + HARD + b'x' + HARD)
|
|
811
|
+
assert doc.meta['page']['lh_48'] == 8.0
|
|
812
|
+
assert doc.meta['page']['text_lines'] == 55
|
|
813
|
+
|
|
814
|
+
def test_page_geometry_lh_zero_and_auto_rejected():
|
|
815
|
+
# .lh 0 is meaningless and .lh a is auto-leading -- both leave the
|
|
816
|
+
# default standing (and stay preserved verbatim in dot_commands)
|
|
817
|
+
for arg in (b'.LH 0', b'.LH A'):
|
|
818
|
+
doc = core.parse_ws(arg + HARD + b'x' + HARD)
|
|
819
|
+
assert doc.meta['page']['lh_48'] == 8.0
|
|
820
|
+
assert doc.meta['page']['lh_source'] == 'default'
|
|
821
|
+
assert doc.meta['page']['text_lines'] == 55
|
|
822
|
+
|
|
823
|
+
def test_page_geometry_ls_recorded_but_never_divides_capacity():
|
|
824
|
+
# the trap the manual defuses: line-spacing blanks are LITERAL lines in
|
|
825
|
+
# the file ("when you use line spacing, the blank lines become part of
|
|
826
|
+
# the file" -- WS7 manual, "Line Spacing"), so the body already carries
|
|
827
|
+
# them; dividing capacity by .ls would double-count.
|
|
828
|
+
doc = core.parse_ws(b'.LS 2' + HARD + b'x' + HARD)
|
|
829
|
+
page = doc.meta['page']
|
|
830
|
+
assert page['ls'] == 2.0 and page['ls_source'] == 'file'
|
|
831
|
+
assert page['text_lines'] == 55 # unchanged
|
|
832
|
+
|
|
833
|
+
def test_page_geometry_ls_out_of_range_rejected():
|
|
834
|
+
# spec: "a line spacing of between 1 and 9"
|
|
835
|
+
for arg in (b'.LS 0', b'.LS 12'):
|
|
836
|
+
doc = core.parse_ws(arg + HARD + b'x' + HARD)
|
|
837
|
+
assert doc.meta['page']['ls'] == 1.0
|
|
838
|
+
assert doc.meta['page']['ls_source'] == 'default'
|
|
839
|
+
|
|
840
|
+
def test_page_geometry_hm_fm_parsed_but_reserve_no_space():
|
|
841
|
+
# header/footer margins position header and footer INSIDE .mt/.mb
|
|
842
|
+
# (".MT ... The header is printed within this margin") -- parsed with
|
|
843
|
+
# provenance for --diagnose, never subtracted from capacity
|
|
844
|
+
doc = core.parse_ws(b'.HM 1' + HARD + b'.FM 3' + HARD + b'x' + HARD)
|
|
845
|
+
page = doc.meta['page']
|
|
846
|
+
assert page['hm_lines'] == 1.0 and page['hm_source'] == 'file'
|
|
847
|
+
assert page['fm_lines'] == 3.0 and page['fm_source'] == 'file'
|
|
848
|
+
assert page['text_lines'] == 55 # unchanged
|
|
849
|
+
|
|
850
|
+
def test_page_geometry_absurd_margins_clamp_not_crash():
|
|
851
|
+
# margins that eat the whole page (garbage in a misdetected binary)
|
|
852
|
+
# degrade to a 1-line model, never zero/negative/crash
|
|
853
|
+
doc = core.parse_ws(b'.PL 12' + HARD + b'.MT 40' + HARD + b'.MB 40' + HARD + b'x' + HARD)
|
|
854
|
+
assert doc.meta['page']['text_lines'] == 1
|
|
855
|
+
|
|
856
|
+
def test_pdf_printed_top_offset_follows_mt():
|
|
857
|
+
# the default .mt 3 IS the 36pt top this emitter always used; a bigger
|
|
858
|
+
# .mt moves the text start down in real points (1 line = 12pt at 6 LPI)
|
|
859
|
+
from ctrlkd.pdf import _printed_top
|
|
860
|
+
assert _printed_top(core.parse_ws(b'x' + HARD)) == 36
|
|
861
|
+
assert _printed_top(core.parse_ws(b'.MT 6' + HARD + b'x' + HARD)) == 72
|
|
862
|
+
|
|
863
|
+
def test_pdf_printed_lead_follows_lh():
|
|
864
|
+
# .lh 8 IS the 12pt lead; .lh 16 prints double-spaced at 24pt
|
|
865
|
+
from ctrlkd.pdf import _printed_lead
|
|
866
|
+
assert _printed_lead(core.parse_ws(b'x' + HARD)) == 12.0
|
|
867
|
+
assert _printed_lead(core.parse_ws(b'.LH 16' + HARD + b'x' + HARD)) == 24.0
|
|
868
|
+
|
|
869
|
+
def test_pdf_output_bytes_carry_mt_top_and_lh_lead():
|
|
870
|
+
# end-to-end: the geometry must reach the CONTENT STREAM, not just the
|
|
871
|
+
# helpers -- .mt 6 starts text at 72pt from the top, .lh 16 spaces
|
|
872
|
+
# baselines 24pt apart. Read the Td y-coordinates back out of the bytes.
|
|
873
|
+
import re
|
|
874
|
+
from ctrlkd.pdf import emit_pdf
|
|
875
|
+
data = (b'.MT 6' + HARD + b'.LH 16' + HARD +
|
|
876
|
+
b'Line one.' + HARD + b'Line two.' + HARD + b'Line three.' + HARD)
|
|
877
|
+
pdf = emit_pdf(core.parse_ws(data), mode='printed')
|
|
878
|
+
ys = [float(m) for m in re.findall(rb'[\d.]+ ([\d.]+) Td', pdf)]
|
|
879
|
+
assert ys[0] == 792 - 72 - 12 # top from .mt, not fixed 36
|
|
880
|
+
assert ys[0] - ys[1] == 24.0 # lead from .lh, not fixed 12
|
|
881
|
+
assert ys[1] - ys[2] == 24.0
|
|
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
|
+
|
|
973
|
+
def test_pdf_printstream_capacity_is_the_full_page():
|
|
974
|
+
# a print stream IS the printed page -- its margin blanks travel in-band,
|
|
975
|
+
# so the page budget is the FULL 66 lines of a Letter page: anything
|
|
976
|
+
# smaller could split a physical page the printer produced whole
|
|
977
|
+
from ctrlkd.pdf import _printed_cap, _printed_top
|
|
978
|
+
doc = core.parse_printstream(b'line one\r\nline two\r\n')
|
|
979
|
+
assert _printed_cap(doc) == 66
|
|
980
|
+
assert _printed_top(doc) == 36 # fixed: not .mt-derived
|
|
981
|
+
|
|
781
982
|
# ---------------------------------------------------------------- small parser additions
|
|
782
983
|
|
|
783
984
|
def _ws7_tab(size_hmi, tab_type_byte, tenths=0):
|
|
@@ -874,6 +1075,8 @@ def test_pdf_printed_footnote_splits_with_continuation_and_loses_nothing():
|
|
|
874
1075
|
from ctrlkd.pdf import _doc_to_pagelines, CONTINUATION_TEXT
|
|
875
1076
|
words = [f'word{i:03d}' for i in range(80)]
|
|
876
1077
|
data = (b'.PL 18' + HARD + # a small page: forces a split
|
|
1078
|
+
b'.MT 3' + HARD + b'.MB 3' + HARD + # stated so capacity is 18-3-3=12,
|
|
1079
|
+
# not left to the 3+8 defaults
|
|
877
1080
|
ws7_block(0x00) +
|
|
878
1081
|
b'First body line has the note' + ws7_note(0x03, ' '.join(words).encode(), number=0) +
|
|
879
1082
|
b' right here.' + HARD +
|
|
@@ -1041,7 +1244,9 @@ def test_pdf_printed_no_page_ever_exceeds_its_capacity():
|
|
|
1041
1244
|
# once even one body line has been placed on a terminal page.
|
|
1042
1245
|
from ctrlkd.pdf import _doc_to_pagelines, _printed_cap
|
|
1043
1246
|
words = [f'word{i:03d}' for i in range(60)]
|
|
1044
|
-
data = (b'.PL 12' + HARD +
|
|
1247
|
+
data = (b'.PL 12' + HARD +
|
|
1248
|
+
b'.MT 3' + HARD + b'.MB 3' + HARD + # capacity 12-3-3=6, margins stated
|
|
1249
|
+
ws7_block(0x00) +
|
|
1045
1250
|
b'Only line has a note' + ws7_note(0x03, ' '.join(words).encode(), number=0) +
|
|
1046
1251
|
b' here.' + HARD)
|
|
1047
1252
|
doc = core.parse_ws(data)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|