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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrl-kd
3
- Version: 1.3.0
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` paginates with WordStar's own vertical model
81
- `.pl − .mt − .mb` at the `.lh` line height, 55 text lines for WordStar's
82
- defaults, not a guessed 1-inch margin and `--diagnose` says whether each
83
- figure came from the file or from the default.
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` paginates with WordStar's own vertical model
62
- `.pl − .mt − .mb` at the `.lh` line height, 55 text lines for WordStar's
63
- defaults, not a guessed 1-inch margin and `--diagnose` says whether each
64
- figure came from the file or from the default.
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 = "1.3.0"
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: 1.3.0
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` paginates with WordStar's own vertical model
81
- `.pl − .mt − .mb` at the `.lh` line height, 55 text lines for WordStar's
82
- defaults, not a guessed 1-inch margin and `--diagnose` says whether each
83
- figure came from the file or from the default.
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, Document, Block, Line, Span
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__ = '1.3.0'
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 = 0.0 # no default is stated in the spec for .po; 0 (flush
264
- # with the paper edge) is the least presumptuous
265
- # reading rather than a remembered/guessed figure.
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} # everything else: lines at 6 LPI
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
- t = cur_line.spans[-1].text if cur_line.spans else ''
721
- if t and not t.endswith((' ', '-')):
722
- cur_line.spans.append(Span(' ', cur_line.spans[-1].styles))
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
- for line in b.lines:
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) for line in b.lines]
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
- txt = ' '.join(_html_line(line, refs, keep) for line in b.lines).strip()
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.lines]
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
- for line in b.lines:
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
- for line in b.lines:
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
- y = page_h - top - SIZE
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 = MARGIN
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
- size = 8 if sup else SIZE
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(), size, rise, x, y, _esc(text)))
471
- w = len(text) * size * 0.6
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
- assert page['po_cols'] == 0.0 and page['po_source'] == 'default'
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