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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrl-kd
3
- Version: 1.2.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
@@ -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`, so `--mode
80
- printed` uses the page the author set up rather than an assumed one — and
81
- `--diagnose` says whether the size came from the file or from the default.
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`, so `--mode
61
- printed` uses the page the author set up rather than an assumed one — and
62
- `--diagnose` says whether the size came from the file or from the default.
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 = "1.2.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.2.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
@@ -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`, so `--mode
80
- printed` uses the page the author set up rather than an assumed one — and
81
- `--diagnose` says whether the size came from the file or from the default.
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, 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.2.0'
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 = 0.0 # no default is stated in the spec for .po; 0 (flush
261
- # with the paper edge) is the least presumptuous
262
- # 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.)
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 = _resolve_cols_arg if name == b'PO' else _resolve_lines_arg
344
- page[key] = resolver(value, unit)
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
- t = cur_line.spans[-1].text if cur_line.spans else ''
661
- if t and not t.endswith((' ', '-')):
662
- 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
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
- 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,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 # print streams carry their own top-margin blanks
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
- LINES_PRINTED = (PAGE_H - 2 * 36) // LEAD # 60 -- the US Letter default;
25
- # printed mode's real per-document
26
- # figure comes from _printed_cap(),
27
- # which honours .pl-derived geometry
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 - 2 * TOP_PRINTED) // LEAD)
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
- 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)):
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
- 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
422
498
  for line in pagelines:
423
- x = MARGIN
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
- size = 8 if sup else SIZE
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(), size, rise, x, y, _esc(text)))
433
- w = len(text) * size * 0.6
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 -= LEAD
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 = TOP_PRINTED if printed else TOP_MODERN
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
- 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
@@ -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 + ws7_block(0x00) +
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