ctrl-kd 1.2.0__tar.gz → 1.3.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: 1.3.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,11 @@ 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`, 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.
82
84
  In `printed` mode footnotes are laid out the way WordStar laid them out: at
83
85
  the foot of the page that references them, behind a twenty-dash separator,
84
86
  split across pages with `...Continued...` when they do not fit.
@@ -57,9 +57,11 @@ 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`, 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.
63
65
  In `printed` mode footnotes are laid out the way WordStar laid them out: at
64
66
  the foot of the page that references them, behind a twenty-dash separator,
65
67
  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 = "1.3.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: 1.3.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,11 @@ 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`, 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.
82
84
  In `printed` mode footnotes are laid out the way WordStar laid them out: at
83
85
  the foot of the page that references them, behind a twenty-dash separator,
84
86
  split across pages with `...Continued...` when they do not fit.
@@ -5,4 +5,4 @@ from .emit import (emit_text, emit_markdown, emit_html, emit_rtf,
5
5
  from .pdf import emit_pdf # registers the 'pdf' format
6
6
  from .convert import convert, select_notes, DEFAULT_NOTE_KINDS, ALL_NOTE_KINDS
7
7
 
8
- __version__ = '1.2.0'
8
+ __version__ = '1.3.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
 
@@ -239,7 +240,9 @@ _DOT_CMD_RE = re.compile(rb'^\.([A-Za-z]{1,3})\s*(.*)$')
239
240
  _DOT_NUM_RE = re.compile(rb'^\s*([0-9]*\.?[0-9]+)\s*("|[A-Za-z]{1,2})?')
240
241
 
241
242
  _PAGE_DOT_KEYS = {b'PL': 'pl_lines', b'MT': 'mt_lines',
242
- b'MB': 'mb_lines', b'PO': 'po_cols'}
243
+ b'MB': 'mb_lines', b'PO': 'po_cols',
244
+ b'HM': 'hm_lines', b'FM': 'fm_lines',
245
+ b'LH': 'lh_48', b'LS': 'ls'}
243
246
 
244
247
  # Named page sizes at 6 LPI (WordStar 7.0 file format spec: ".PL ... assuming
245
248
  # 6 lines per inch. An eleven inch page contains 66 lines."): 66 lines/11in
@@ -260,6 +263,10 @@ DEFAULT_MB_LINES = 8.0 # spec: ".MB ... The default value is 8 lines."
260
263
  DEFAULT_PO_COLS = 0.0 # no default is stated in the spec for .po; 0 (flush
261
264
  # with the paper edge) is the least presumptuous
262
265
  # reading rather than a remembered/guessed figure.
266
+ DEFAULT_HM_LINES = 2.0 # spec: ".HM ... Default is 2." (header sits INSIDE .mt)
267
+ DEFAULT_FM_LINES = 2.0 # spec: ".FM ... Default is 2." (footer sits INSIDE .mb)
268
+ DEFAULT_LH_48 = 8.0 # spec: ".LH ... The default is 8/48 or 6 lines per inch."
269
+ DEFAULT_LS = 1.0 # single spacing (WS7 manual, "Line Spacing")
263
270
 
264
271
  def _dot_arg_inches(value: float, unit: bytes | None):
265
272
  """Convert a dot-command argument's optional unit suffix to inches.
@@ -287,6 +294,57 @@ def _resolve_cols_arg(value: float, unit: bytes | None) -> float:
287
294
  inches = _dot_arg_inches(value, unit)
288
295
  return value if inches is None else inches * 10.0
289
296
 
297
+ def _resolve_lh_arg(value: float, unit: bytes | None):
298
+ """.lh argument -> line height in 1/48in units. Unit-less IS 48ths (WS7
299
+ manual: "You can also type the dot command in 48ths of an inch. For
300
+ example, .lh 8 is 8/48 inch, or the standard 6 lines per inch"); an
301
+ explicit unit suffix converts. `.lh a` (auto-leading) never reaches here
302
+ -- the numeric matcher won't match it, so it stays default + verbatim.
303
+ A non-positive height is meaningless: rejected (None), default stands."""
304
+ inches = _dot_arg_inches(value, unit)
305
+ resolved = value if inches is None else inches * 48.0
306
+ return resolved if resolved > 0 else None
307
+
308
+ def _resolve_ls_arg(value: float, unit: bytes | None):
309
+ """.ls argument -> line spacing. "A line spacing of between 1 and 9"
310
+ (WS7 file format spec); anything else is junk, rejected (None). Any unit
311
+ suffix is likewise junk -- spacing is a count, not a measure."""
312
+ if unit is not None or not 1 <= value <= 9:
313
+ return None
314
+ return value
315
+
316
+ _PAGE_DOT_RESOLVERS = {'po_cols': _resolve_cols_arg, 'lh_48': _resolve_lh_arg,
317
+ 'ls': _resolve_ls_arg} # everything else: lines at 6 LPI
318
+
319
+ def _text_lines_per_page(pl_lines: float, mt_lines: float, mb_lines: float,
320
+ lh_48: float) -> int:
321
+ """Printed text lines per page -- WordStar's own vertical model (WS7
322
+ manual, "Page Layout"): "The top and bottom margins define the space
323
+ between the text and the top and bottom of the paper. On an 8.5 x 11-inch
324
+ page, if the top margin is .33 inches and the bottom margin is 1.33
325
+ inches, the space left for text is 9.33 inches." Lines available is that
326
+ text height divided by the line height (.lh, 1/48in units): "Changing the
327
+ line height affects the number of lines that can be printed on a page."
328
+ WordStar's own defaults (.pl 66 .mt 3 .mb 8 .lh 8) give 55.
329
+
330
+ Deliberately NOT in the formula:
331
+ - .hm/.fm -- the header prints WITHIN .mt and the footer WITHIN .mb
332
+ (".MT ... The header is printed within this margin"; ".MB ... The
333
+ footer or page number is printed within this margin"), so they position
334
+ header/footer inside space already subtracted, never reserve more.
335
+ - .ls -- line-spacing blanks are literal lines in the file ("when you use
336
+ line spacing, the blank lines become part of the file", WS7 manual,
337
+ "Line Spacing"), so the body text already carries them; dividing
338
+ capacity by .ls would double-count.
339
+
340
+ Unit-less .mt/.mb are lines at the fixed 6 LPI baseline (the module-note
341
+ assumption); .lh at parse time is resolved once per document (first
342
+ occurrence wins), not tracked per-line."""
343
+ usable = pl_lines - mt_lines - mb_lines # lines at 6 LPI
344
+ if not math.isfinite(usable) or not math.isfinite(lh_48) or lh_48 <= 0:
345
+ return 1
346
+ return max(1, int(usable * 8.0 / lh_48))
347
+
290
348
  def _resolve_page_size(pl_lines: float):
291
349
  """pl_lines -> (height_in, size_name). Snaps to a named size when close;
292
350
  otherwise reports the raw geometry under 'Custom' rather than forcing a
@@ -340,8 +398,10 @@ def _parse_page_dot(cmd: bytes, page: dict, meta_extra: dict):
340
398
  return
341
399
  value = float(num.group(1))
342
400
  unit = num.group(2)
343
- resolver = _resolve_cols_arg if name == b'PO' else _resolve_lines_arg
344
- page[key] = resolver(value, unit)
401
+ resolver = _PAGE_DOT_RESOLVERS.get(key, _resolve_lines_arg)
402
+ resolved = resolver(value, unit)
403
+ if resolved is not None: # junk argument: default stands
404
+ page[key] = resolved
345
405
  elif name in (b'PT', b'PSA', b'PSB'):
346
406
  # WordTsar's own invented dot commands (its source calls them "not a
347
407
  # Wordstar command"). A real WordStar file never contains these --
@@ -676,6 +736,10 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
676
736
  mt_lines = page.get('mt_lines')
677
737
  mb_lines = page.get('mb_lines')
678
738
  po_cols = page.get('po_cols')
739
+ hm_lines = page.get('hm_lines')
740
+ fm_lines = page.get('fm_lines')
741
+ lh_48 = page.get('lh_48')
742
+ ls = page.get('ls')
679
743
  # Exposed per the IR contract: a consumer must be able to distinguish
680
744
  # "Legal (from file)" from "Letter (default)" -- provenance lives
681
745
  # alongside every resolved figure, not just the page size.
@@ -690,7 +754,22 @@ def parse_ws(data: bytes, encoding: str = 'cp437') -> Document:
690
754
  'mb_source': 'file' if mb_lines is not None else 'default',
691
755
  'po_cols': po_cols if po_cols is not None else DEFAULT_PO_COLS,
692
756
  'po_source': 'file' if po_cols is not None else 'default',
757
+ 'hm_lines': hm_lines if hm_lines is not None else DEFAULT_HM_LINES,
758
+ 'hm_source': 'file' if hm_lines is not None else 'default',
759
+ 'fm_lines': fm_lines if fm_lines is not None else DEFAULT_FM_LINES,
760
+ 'fm_source': 'file' if fm_lines is not None else 'default',
761
+ 'lh_48': lh_48 if lh_48 is not None else DEFAULT_LH_48,
762
+ 'lh_source': 'file' if lh_48 is not None else 'default',
763
+ 'ls': ls if ls is not None else DEFAULT_LS,
764
+ 'ls_source': 'file' if ls is not None else 'default',
693
765
  }
766
+ # The one derived figure consumers actually need: printed text lines per
767
+ # page, from WordStar's own vertical model (see _text_lines_per_page for
768
+ # the formula and the deliberate exclusions). Defaults -> 55, NOT the 60
769
+ # a naive 1in-margin Letter computation gives.
770
+ doc.meta['page']['text_lines'] = _text_lines_per_page(
771
+ doc.meta['page']['pl_lines'], doc.meta['page']['mt_lines'],
772
+ doc.meta['page']['mb_lines'], doc.meta['page']['lh_48'])
694
773
  if meta_extra:
695
774
  doc.meta.update(meta_extra)
696
775
  return doc
@@ -19,12 +19,14 @@ PAGE_W, PAGE_H = 612, 792 # US Letter, points
19
19
  MARGIN = 72 # 1 inch
20
20
  SIZE, LEAD = 12, 12 # 10 CPI pica x 6 LPI — the dot-matrix standard;
21
21
  # a 65-col WordStar line is exactly 6.5in
22
- TOP_MODERN, TOP_PRINTED = 72, 36 # print streams carry their own top-margin blanks
22
+ TOP_MODERN, TOP_PRINTED = 72, 36 # printed: default when a stream has no geometry
23
+ # meta (its margin blanks travel in-band); WS docs
24
+ # get an .mt-derived top from _printed_top()
23
25
  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
26
+ # Printed capacity is per-document: _printed_cap() -- WordStar's own model
27
+ # (.pl - .mt - .mb at the .lh line height; 55 for WordStar's defaults). The
28
+ # old hardcoded (PAGE_H - 2*36)//LEAD = 60 was a naive Letter computation
29
+ # that matched no WordStar the manual describes.
28
30
  MAX_COLS = int((PAGE_W - 2 * MARGIN) / (SIZE * 0.6)) # 65 — WordStar's own margin
29
31
 
30
32
  # Period-authentic footnote layout (Printed mode only -- WordStar Professional
@@ -55,9 +57,45 @@ def _resolved_page_height(doc, printed):
55
57
  def _printed_cap(doc):
56
58
  """Lines of vertical room on a printed page for THIS document -- the
57
59
  cap used both for plain pagination and as the footnote layout's page
58
- budget (see _paginate_printed_notes)."""
60
+ budget (see _paginate_printed_notes).
61
+
62
+ WS documents carry doc.meta['page'] and get WordStar's own vertical
63
+ model (core._text_lines_per_page: .pl - .mt - .mb at the .lh line
64
+ height -- 55 for WordStar's defaults, NOT the 60 a naive 1in-margin
65
+ computation gives). Print streams have no 'page' meta and ARE the
66
+ printed page -- their margin blanks travel in-band -- so their budget
67
+ is the FULL page height in lines (66 on Letter): anything smaller
68
+ would split a physical page that the printer produced whole."""
69
+ page = doc.meta.get('page')
70
+ if page is not None:
71
+ return max(FOOTNOTE_FLOOR + 1, page.get('text_lines', 55))
59
72
  page_h = _resolved_page_height(doc, True)
60
- return max(FOOTNOTE_FLOOR + 1, (page_h - 2 * TOP_PRINTED) // LEAD)
73
+ return max(FOOTNOTE_FLOOR + 1, int(page_h // LEAD))
74
+
75
+ def _printed_top(doc):
76
+ """Top-of-text offset in points for printed mode. WS documents start
77
+ where .mt says (lines at 6 LPI -> 12pt each; the default .mt 3 is the
78
+ 36pt this emitter always used). Print streams keep the fixed 36pt --
79
+ their own top-margin blanks are in the data (minus the machine-margin
80
+ strip in _doc_to_pagelines). Clamped inside the page so garbage .mt
81
+ from a misdetected binary degrades to an ugly page, never an absurd
82
+ coordinate space."""
83
+ page = doc.meta.get('page')
84
+ if page is None:
85
+ return TOP_PRINTED
86
+ page_h = _resolved_page_height(doc, True)
87
+ return max(0, min(round(page.get('mt_lines', 3.0) * 12), page_h - LEAD))
88
+
89
+ def _printed_lead(doc):
90
+ """Baseline-to-baseline distance in points for printed mode: .lh is
91
+ 1/48in units, a point is 1/72in -> lh * 1.5. Default .lh 8 IS the 12pt
92
+ lead this emitter always used. Print streams (no 'page' meta) keep the
93
+ fixed LEAD."""
94
+ page = doc.meta.get('page')
95
+ if page is None:
96
+ return LEAD
97
+ lh = page.get('lh_48', 8.0)
98
+ return lh * 1.5 if lh > 0 else LEAD
61
99
 
62
100
  FONTS = {(False, False): 'F1', (True, False): 'F2',
63
101
  (False, True): 'F3', (True, True): 'F4'}
@@ -416,7 +454,7 @@ def _coalesce(line):
416
454
  out.append([text, styles])
417
455
  return out
418
456
 
419
- def _page_stream(pagelines, top, page_h=PAGE_H):
457
+ def _page_stream(pagelines, top, page_h=PAGE_H, lead=LEAD):
420
458
  ops = []
421
459
  y = page_h - top - SIZE
422
460
  for line in pagelines:
@@ -436,7 +474,7 @@ def _page_stream(pagelines, top, page_h=PAGE_H):
436
474
  if 'strike' in styles and text.strip():
437
475
  ops.append(b'0.6 w %.1f %.1f m %.1f %.1f l S' % (x, y + 3, x + w, y + 3))
438
476
  x += w
439
- y -= LEAD
477
+ y -= lead
440
478
  return b'\n'.join(ops)
441
479
 
442
480
  @emitter('pdf')
@@ -445,7 +483,9 @@ def emit_pdf(doc, mode='modern', **options):
445
483
  stream per page, xref. Returns bytes — PDF is a binary format."""
446
484
  printed = mode == 'printed' or _printed(doc)
447
485
  pages = _doc_to_pagelines(doc, printed)
448
- top = TOP_PRINTED if printed else TOP_MODERN
486
+ top = _printed_top(doc) if printed else TOP_MODERN # .mt-derived for WS docs;
487
+ # default .mt 3 IS the old 36pt
488
+ lead = _printed_lead(doc) if printed else LEAD # .lh-derived; .lh 8 IS 12pt
449
489
  page_h = _resolved_page_height(doc, printed) # file geometry wins in
450
490
  # printed mode (Task: .pl);
451
491
  # modern stays fixed Letter
@@ -476,7 +516,7 @@ def emit_pdf(doc, mode='modern', **options):
476
516
  b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] '
477
517
  b'/Resources << /Font << %s >> >> /Contents %d 0 R >>'
478
518
  % (PAGE_W, page_h, font_dict, cnum)))
479
- stream = _page_stream(pl, top, page_h)
519
+ stream = _page_stream(pl, top, page_h, lead)
480
520
  objs.append((cnum, b'<< /Length %d >>\nstream\n%s\nendstream'
481
521
  % (len(stream), stream)))
482
522
 
@@ -778,6 +778,115 @@ def test_page_geometry_malformed_pl_does_not_crash():
778
778
  assert doc.meta['page']['pl_lines'] == 66.0
779
779
  assert doc.meta['page']['size_source'] == 'default'
780
780
 
781
+ # ------------------------------------------- the vertical model (text_lines)
782
+
783
+ def test_page_geometry_defaults_give_wordstar_55_not_60():
784
+ # THE fix this section exists for: WordStar's own defaults (.pl 66
785
+ # .mt 3 .mb 8 .lh 8) put 55 text lines on a page -- the manual's model,
786
+ # (pl - mt - mb) at 6 LPI -- not the 60 a naive 1in-margin Letter
787
+ # computation produced for every document before 1.3.0.
788
+ from ctrlkd.pdf import _printed_cap
789
+ doc = core.parse_ws(b'Body text.' + HARD)
790
+ assert doc.meta['page']['text_lines'] == 55
791
+ assert _printed_cap(doc) == 55
792
+
793
+ def test_page_geometry_mt_mb_change_capacity():
794
+ doc = core.parse_ws(b'.MT 6' + HARD + b'.MB 6' + HARD + b'x' + HARD)
795
+ assert doc.meta['page']['text_lines'] == 54 # 66 - 6 - 6
796
+
797
+ def test_page_geometry_lh_parsed_and_scales_capacity():
798
+ # .lh is 1/48in units: .lh 16 doubles the line height, halving capacity
799
+ # (the manual: "Changing the line height affects the number of lines
800
+ # that can be printed on a page"). floor(55 * 8 / 16) = 27.
801
+ doc = core.parse_ws(b'.LH 16' + HARD + b'x' + HARD)
802
+ page = doc.meta['page']
803
+ assert page['lh_48'] == 16.0 and page['lh_source'] == 'file'
804
+ assert page['text_lines'] == 27
805
+
806
+ def test_page_geometry_lh_unit_suffix_converts():
807
+ # .lh 12p = 12/72in = 8/48in -> the standard height, stated in points
808
+ doc = core.parse_ws(b'.LH 12P' + HARD + b'x' + HARD)
809
+ assert doc.meta['page']['lh_48'] == 8.0
810
+ assert doc.meta['page']['text_lines'] == 55
811
+
812
+ def test_page_geometry_lh_zero_and_auto_rejected():
813
+ # .lh 0 is meaningless and .lh a is auto-leading -- both leave the
814
+ # default standing (and stay preserved verbatim in dot_commands)
815
+ for arg in (b'.LH 0', b'.LH A'):
816
+ doc = core.parse_ws(arg + HARD + b'x' + HARD)
817
+ assert doc.meta['page']['lh_48'] == 8.0
818
+ assert doc.meta['page']['lh_source'] == 'default'
819
+ assert doc.meta['page']['text_lines'] == 55
820
+
821
+ def test_page_geometry_ls_recorded_but_never_divides_capacity():
822
+ # the trap the manual defuses: line-spacing blanks are LITERAL lines in
823
+ # the file ("when you use line spacing, the blank lines become part of
824
+ # the file" -- WS7 manual, "Line Spacing"), so the body already carries
825
+ # them; dividing capacity by .ls would double-count.
826
+ doc = core.parse_ws(b'.LS 2' + HARD + b'x' + HARD)
827
+ page = doc.meta['page']
828
+ assert page['ls'] == 2.0 and page['ls_source'] == 'file'
829
+ assert page['text_lines'] == 55 # unchanged
830
+
831
+ def test_page_geometry_ls_out_of_range_rejected():
832
+ # spec: "a line spacing of between 1 and 9"
833
+ for arg in (b'.LS 0', b'.LS 12'):
834
+ doc = core.parse_ws(arg + HARD + b'x' + HARD)
835
+ assert doc.meta['page']['ls'] == 1.0
836
+ assert doc.meta['page']['ls_source'] == 'default'
837
+
838
+ def test_page_geometry_hm_fm_parsed_but_reserve_no_space():
839
+ # header/footer margins position header and footer INSIDE .mt/.mb
840
+ # (".MT ... The header is printed within this margin") -- parsed with
841
+ # provenance for --diagnose, never subtracted from capacity
842
+ doc = core.parse_ws(b'.HM 1' + HARD + b'.FM 3' + HARD + b'x' + HARD)
843
+ page = doc.meta['page']
844
+ assert page['hm_lines'] == 1.0 and page['hm_source'] == 'file'
845
+ assert page['fm_lines'] == 3.0 and page['fm_source'] == 'file'
846
+ assert page['text_lines'] == 55 # unchanged
847
+
848
+ def test_page_geometry_absurd_margins_clamp_not_crash():
849
+ # margins that eat the whole page (garbage in a misdetected binary)
850
+ # degrade to a 1-line model, never zero/negative/crash
851
+ doc = core.parse_ws(b'.PL 12' + HARD + b'.MT 40' + HARD + b'.MB 40' + HARD + b'x' + HARD)
852
+ assert doc.meta['page']['text_lines'] == 1
853
+
854
+ def test_pdf_printed_top_offset_follows_mt():
855
+ # the default .mt 3 IS the 36pt top this emitter always used; a bigger
856
+ # .mt moves the text start down in real points (1 line = 12pt at 6 LPI)
857
+ from ctrlkd.pdf import _printed_top
858
+ assert _printed_top(core.parse_ws(b'x' + HARD)) == 36
859
+ assert _printed_top(core.parse_ws(b'.MT 6' + HARD + b'x' + HARD)) == 72
860
+
861
+ def test_pdf_printed_lead_follows_lh():
862
+ # .lh 8 IS the 12pt lead; .lh 16 prints double-spaced at 24pt
863
+ from ctrlkd.pdf import _printed_lead
864
+ assert _printed_lead(core.parse_ws(b'x' + HARD)) == 12.0
865
+ assert _printed_lead(core.parse_ws(b'.LH 16' + HARD + b'x' + HARD)) == 24.0
866
+
867
+ def test_pdf_output_bytes_carry_mt_top_and_lh_lead():
868
+ # end-to-end: the geometry must reach the CONTENT STREAM, not just the
869
+ # helpers -- .mt 6 starts text at 72pt from the top, .lh 16 spaces
870
+ # baselines 24pt apart. Read the Td y-coordinates back out of the bytes.
871
+ import re
872
+ from ctrlkd.pdf import emit_pdf
873
+ data = (b'.MT 6' + HARD + b'.LH 16' + HARD +
874
+ b'Line one.' + HARD + b'Line two.' + HARD + b'Line three.' + HARD)
875
+ pdf = emit_pdf(core.parse_ws(data), mode='printed')
876
+ ys = [float(m) for m in re.findall(rb'[\d.]+ ([\d.]+) Td', pdf)]
877
+ assert ys[0] == 792 - 72 - 12 # top from .mt, not fixed 36
878
+ assert ys[0] - ys[1] == 24.0 # lead from .lh, not fixed 12
879
+ assert ys[1] - ys[2] == 24.0
880
+
881
+ def test_pdf_printstream_capacity_is_the_full_page():
882
+ # a print stream IS the printed page -- its margin blanks travel in-band,
883
+ # so the page budget is the FULL 66 lines of a Letter page: anything
884
+ # smaller could split a physical page the printer produced whole
885
+ from ctrlkd.pdf import _printed_cap, _printed_top
886
+ doc = core.parse_printstream(b'line one\r\nline two\r\n')
887
+ assert _printed_cap(doc) == 66
888
+ assert _printed_top(doc) == 36 # fixed: not .mt-derived
889
+
781
890
  # ---------------------------------------------------------------- small parser additions
782
891
 
783
892
  def _ws7_tab(size_hmi, tab_type_byte, tenths=0):
@@ -874,6 +983,8 @@ def test_pdf_printed_footnote_splits_with_continuation_and_loses_nothing():
874
983
  from ctrlkd.pdf import _doc_to_pagelines, CONTINUATION_TEXT
875
984
  words = [f'word{i:03d}' for i in range(80)]
876
985
  data = (b'.PL 18' + HARD + # a small page: forces a split
986
+ b'.MT 3' + HARD + b'.MB 3' + HARD + # stated so capacity is 18-3-3=12,
987
+ # not left to the 3+8 defaults
877
988
  ws7_block(0x00) +
878
989
  b'First body line has the note' + ws7_note(0x03, ' '.join(words).encode(), number=0) +
879
990
  b' right here.' + HARD +
@@ -1041,7 +1152,9 @@ def test_pdf_printed_no_page_ever_exceeds_its_capacity():
1041
1152
  # once even one body line has been placed on a terminal page.
1042
1153
  from ctrlkd.pdf import _doc_to_pagelines, _printed_cap
1043
1154
  words = [f'word{i:03d}' for i in range(60)]
1044
- data = (b'.PL 12' + HARD + ws7_block(0x00) +
1155
+ data = (b'.PL 12' + HARD +
1156
+ b'.MT 3' + HARD + b'.MB 3' + HARD + # capacity 12-3-3=6, margins stated
1157
+ ws7_block(0x00) +
1045
1158
  b'Only line has a note' + ws7_note(0x03, ' '.join(words).encode(), number=0) +
1046
1159
  b' here.' + HARD)
1047
1160
  doc = core.parse_ws(data)
File without changes
File without changes
File without changes
File without changes
File without changes