aboutsummaryrefslogtreecommitdiffhomepage
path: root/org/out_typst.org
blob: 7cefb1c72170390433d343db1d12bb9935c42ef6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
-*- mode: org -*-
#+TITLE:       sisudoc spine (doc_reform) output latex
#+DESCRIPTION: documents - structuring, publishing in multiple formats & search
#+FILETAGS:    :spine:output:latex:pdf:
#+AUTHOR:      Ralph Amissah
#+EMAIL:       [[mailto:ralph.amissah@gmail.com][ralph.amissah@gmail.com]]
#+COPYRIGHT:   Copyright (C) 2015 (continuously updated, current 2026) Ralph Amissah
#+LANGUAGE:    en
#+STARTUP:     content hideblocks hidestars noindent entitiespretty
#+PROPERTY:    header-args+ :eval never-export :exports code
#+PROPERTY:    header-args+ :noweb yes :padline no
#+PROPERTY:    header-args+ :results silent :cache no
#+PROPERTY:    header-args+ :mkdirp yes
#+OPTIONS:     H:3 num:nil toc:t \n:t ::t |:t ^:nil -:t f:t *:t
- magic single double-quote → " ← FIX changes hilighting behavior (occuring
  after it) in org document. INVESTIGATE (org-mode CONFIG?) FIND & FIX

- [[./doc-reform.org][doc-reform.org]]  [[./][org/]]
- [[./output_hub.org][output_hub]]

* typst & pdf
** _module template_ typst:
*** typst.d module & templates

#+HEADER: :tangle "../src/sisudoc/outputs/io_out/typst.d"
#+HEADER: :noweb yes
#+BEGIN_SRC org
<<doc_header_including_copyright_and_license>>
<<Template_typst_output>>
<<Template_typst_pdf_output>>
#+END_SRC

*** template typst .typ output

#+NAME: Template_typst_output
#+HEADER: :noweb yes
#+BEGIN_SRC d
module sisudoc.outputs.io_out.typst;
@safe:
/+ ↓ typst output: the document as typst markup, for a pdf.
     .
     The new default pdf path (beside the latex one).
       --typst (--typ) writes .typ;
       --pdf writes the .typ and compiles it.
       --latex writes .tex (as before);
     .
     justification for typst as default: pdf is the format on which spine
     depends most on an external toolchain, and various useful parts of an
     object-centric pdf such as: object numbers in the margin; a link from
     an object to its note and back; table of contents using object numbers
     instead of page numbers - are a few lines each here. (Additionally,
     measured against the latex build on the document sample set documents:
     typst has a toolchain of 108 MB where texlive-combined-full is 6.6 GB,
     and requires one compile per pdf rather than two, resulting in a tagged
     pdf, and currently better CJK for Japanese than the latex).
     .
     One .typ per document-language, not one per paper size. The paper and the
     orientation are read from `sys.inputs` with a default, so the same file
     compiles to every paper spine is configured for. The objectcentric table
     of contents means the document does not depend on pages.
     .
     it compiles silently, which means every internal link in it resolves,
     because a link to a label that does not exist is a typst compile error.
+/
template outputTypst() {
  import sisudoc.outputs.io_out;
  import sisudoc.outputs.io_out.rgx;
  import sisudoc.outputs.io_out.paths_output;
  import std.algorithm : max, min;
  import std.array : appender, array, join, replace;
  import std.array : asplit = split;
  import std.conv : to;
  import std.exception : ErrnoException;
  import std.file;
  import std.regex : matchAll, matchFirst, replaceAll, split;
  import std.stdio;
  import std.string : strip;
  mixin spineRgxOut;
  static auto rgx = RgxO();
  enum newline  = "\n";
  enum newlines = "\n\n";
  /+ ↓ the markers that protect emitted typst from the escaping that follows.
       .
       escaping must come last: the abstraction's own markers are built from
       characters typst reads as syntax, so escaping first destroys them. A
       private-use pair, because they cannot occur in a document, and the
       protection nests, so the escaper counts it rather than flagging it.
  +/
  enum keep_open  = "";
  enum keep_close = "";
  string keep(string typst_source) {
    return keep_open ~ typst_source ~ keep_close;
  }
  string unprotect(string txt) {
    return txt.replace(keep_open, "").replace(keep_close, "");
  }
  /+ ↓ a label name typst will accept, from a name the markup chose.
       Prefixed, because a label may not begin with a digit and every object
       number does. Codepoints, not bytes, so that one character gives one
       underscore.
  +/
  string labelOf(string name) {
    string _out = "t";
    foreach (dchar c; name) {
      bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
        || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.';
      if (ok) { _out ~= c; } else { _out ~= '_'; }
    }
    return _out;
  }
  /+ ↓ a typst string literal +/
  string str(string s) {
    auto _out = appender!string;
    _out ~= '"';
    foreach (dchar c; s) {
      if (c == '"' || c == '\\') { _out ~= '\\'; _out ~= c; }
      else if (c == '\n') { _out ~= "\\n"; }
      else { _out ~= c; }
    }
    _out ~= '"';
    return _out.data;
  }
  /+ ↓ everything outside a protected run is escaped.
       .
       The rule is deliberately blunt: every character typst can read as syntax
       is escaped wherever it occurs, not only where it would be read that way.
       typst continues a function call across whatever follows it -
       `#emph[a](b)` reads "(b)" as a second argument list and `#emph[a].b` as a
       field access - so a "(" or a "." of the author's own, landing after any
       call this writer emits, is a compile error. Escaping them everywhere
       makes the set closed, and a closed set is checkable.
       .
       The quotation mark is not typst syntax in markup and is escaped anyway,
       so that a bare one in the written source always delimits a string
       literal; `guardBlockStarts` relies on that to leave a code object alone.
  +/
  string escape(string txt) {
    auto _out = appender!string;
    int depth = 0;
    dchar previous = '\0';
    foreach (dchar c; txt) {
      if (c == keep_open.to!dchar)  { depth++; continue; }
      if (c == keep_close.to!dchar) { if (depth > 0) { depth--; } previous = '\0'; continue; }
      if (depth > 0) { _out ~= c; continue; }
      switch (c) {
      case '\\': case '#': case '[': case ']': case '*': case '_':
      case '$': case '@': case '<': case '>': case '`': case '~':
      case '(': case ')': case '.': case '/': case '"':
        _out ~= '\\'; _out ~= c; break;
      case '-':
        /+ ↓ a second dash would be an en dash, so it is escaped +/
        if (previous == '-') { _out ~= '\\'; }
        _out ~= c;
        break;
      default:
        _out ~= c;
        break;
      }
      previous = c;
    }
    return _out.data;
  }
  /+ ↓ a "- ", "+ " or "= " that begins a line or a content block, which typst
       reads as a list item, an enumeration item and a heading. Special only in
       that position, so guarded there rather than escaped in every hyphen; and
       not inside a string literal, where a guard would be a backslash in the
       middle of somebody's program.
  +/
  string guardBlockStarts(string s) {
    auto _out = appender!string;
    bool in_string = false;
    for (size_t i = 0; i < s.length; i++) {
      char c = s[i];
      _out ~= c;
      if (in_string) {
        if (c == '\\' && i + 1 < s.length) { _out ~= s[++i]; }
        else if (c == '"') { in_string = false; }
        continue;
      }
      if (c == '"') { in_string = true; continue; }
      if ((c == '[' || c == '\n') && i + 2 < s.length
          && (s[i + 1] == '-' || s[i + 1] == '+' || s[i + 1] == '=')
          && s[i + 2] == ' ') {
        _out ~= '\\';
      }
    }
    return _out.data;
  }
  /+ ↓ which sections, in which order.
       The latex sequence, less the endnotes: a note is a footnote here, at the
       foot of the page that refers to it, so a gathered endnotes section would
       put every note in the document twice.
  +/
  string[] typstSections(M)(M doc_matters) {
    string[] _seq;
    foreach (part; doc_matters.has.keys_seq.latex) {
      if (part == "endnotes") { continue; }
      _seq ~= part;
    }
    return _seq;
  }
  /+ ↓ every label this document will define.
       A link to an undefined label does not compile, so the writer has to know
       what it will define before it writes the first link.
  +/
  bool[string] definedTargets(D,M)(const D doc_abstraction, M doc_matters) {
    bool[string] _targets;
    foreach (part; typstSections!()(doc_matters)) {
      foreach (obj; doc_abstraction[part]) {
        if (obj.metainfo.is_of_type == "comment") { continue; }
        if (obj.metainfo.ocn != 0) { _targets[labelOf(obj.metainfo.ocn.to!string)] = true; }
        if (obj.metainfo.identifier.length > 0) {
          _targets[labelOf(obj.metainfo.identifier.to!string)] = true;
        }
        if (obj.tags.heading_lev_anchor_tag.length > 0) {
          _targets[labelOf(obj.tags.heading_lev_anchor_tag.to!string)] = true;
        }
        foreach (at; obj.tags.anchor_tags) {
          if (at.length > 0) { _targets[labelOf(at.to!string)] = true; }
        }
        foreach (m; obj.text.matchAll(rgx.inline_link_anchor)) {
          _targets[labelOf(m["anchor"].to!string)] = true;
        }
      }
    }
    return _targets;
  }
  /+ ↓ the labels placed by `ocn`, on the number in the margin: always, and for
       every numbered object. Claimed first, so that an anchor or an identifier
       carrying the same name is dropped rather than defining a label twice,
       which typst rejects.
  +/
  bool[string] ocnLabels(D,M)(const D doc_abstraction, M doc_matters) {
    bool[string] _placed;
    foreach (part; typstSections!()(doc_matters)) {
      foreach (obj; doc_abstraction[part]) {
        if (obj.metainfo.is_of_type == "comment") { continue; }
        if (obj.metainfo.ocn != 0) { _placed[labelOf(obj.metainfo.ocn.to!string)] = true; }
      }
    }
    return _placed;
  }
  /+ ↓ the preamble: the page, the text, and the three functions the body leans
       on.
       .
       The paper and the orientation come from `sys.inputs`, so one .typ is
       every paper size spine is configured for:
         typst compile --input paper=letter --input orientation=landscape
  +/
  string typstHead(M)(M doc_matters) {
    string _lang = doc_matters.src.language;
    string _region;
    auto _parts = _lang.replace("_", "-").asplit("-");
    _lang = _parts[0];
    if (_parts.length > 1) { _region = _parts[1]; }
    string _title = doc_matters.conf_make_meta.meta.title_full;
    auto _out = appender!string;
    _out ~= "// " ~ doc_matters.generator_program.project_name.strip
      ~ ": the object-centric document abstraction, written as typst\n";
    _out ~= "// Compile with: typst compile <this file>\n";
    _out ~= "// The paper is an input, so this one file is every paper size:\n";
    _out ~= "//   typst compile --input paper=letter --input orientation=landscape ...\n\n";
    _out ~= "#let paper = sys.inputs.at(\"paper\", default: \"a4\")\n";
    _out ~= "#let landscape = sys.inputs.at(\"orientation\", default: \"portrait\")"
      ~ " == \"landscape\"\n\n";
    /+ ↓ the pdf's own metadata, from the document's own. The date is the
         document's and never the clock's: a document typeset twice is one
         document.
    +/
    _out ~= "#set document(\n  title: " ~ str(_title) ~ ",\n";
    if (doc_matters.conf_make_meta.meta.creator_author.length > 0) {
      _out ~= "  author: " ~ str(doc_matters.conf_make_meta.meta.creator_author) ~ ",\n";
    }
    _out ~= "  date: none,\n)\n";
    /+ ↓ the left margin is wide because the object numbers live in it +/
    _out ~= "#set page(\n  paper: paper,\n  flipped: landscape,\n"
      ~ "  margin: (left: 3.4cm, right: 2cm, top: 2.2cm, bottom: 2.2cm),\n"
      ~ "  numbering: \"1\",\n)\n";
    _out ~= "#set text(\n  font: (\"DejaVu Serif\", \"Noto Sans CJK JP\"),\n"
      ~ "  size: 10pt,\n  lang: " ~ str(_lang);
    if (_region.length > 0) { _out ~= ",\n  region: " ~ str(_region); }
    _out ~= ",\n)\n";
    _out ~= "#set par(justify: true, leading: 0.65em)\n";
    /+ ↓ the author's quotation marks and dashes, not typst's. A document is
         reproduced, not restyled
    +/
    _out ~= "#set smartquote(enabled: false)\n";
    _out ~= "#set heading(numbering: none)\n";
    _out ~= "#show heading: it => block(above: 1.4em, below: 0.7em, it)\n";
    _out ~= "#show raw.where(block: true): it => block(\n"
      ~ "  fill: luma(245), inset: 6pt, radius: 2pt, width: 100%, it)\n\n";
    /+ ↓ the object number, in the margin, beside the object's first line, and
         carrying the label every citation of the object points at.
         .
         Typst gets this right being one `place` at a coordinate inside the
         object's own block. (this can be a problem for the latex \ocn
         which is a \marginpar with a \hypertarget, and can fail inside a
         float or a footnote and migrate to the next page when it will not
         fit.)
    +/
    _out ~= "#let ocn(n, body) = block(breakable: true, width: 100%, {\n"
      ~ "  place(left, dx: -1.8cm, dy: 0.2em,\n"
      ~ "    text(size: 7pt, fill: luma(130))[#n#label(\"t\" + str(n))])\n"
      ~ "  body\n})\n";
    /+ ↓ an object with no number of its own still needs its block +/
    _out ~= "#let obj(body) = block(breakable: true, width: 100%, body)\n";
    /+ ↓ monospace as a font rather than as `raw`: typst's raw() takes a
         ,,*string*, so it cannot carry the nested markup an object may have
         inside a monospace run. A code block is a different thing and uses
         raw.
    +/
    _out ~= "#let mono(body) = text(font: (\"DejaVu Sans Mono\",), body)\n";
    /+ ↓ a named target with no output of its own +/
    _out ~= "#let anchor(name) = [#box(width: 0pt)#label(name)]\n\n";
    return _out.data;
  }
  /+ ↓ an image, with its own width where the document declared one. Capped at
       the text width: the declared width is in pixels and a 600 pixel figure
       read as 600 points is wider than the page.
       .
       The opening link marker is kept, as the html writer keeps it: an image in
       the markup sits inside a link whose target may be empty or may be a url,
       and the link handling that follows needs the pair.
  +/
  string images(string txt) {
    return replaceAll!((m) {
      string _file = m["img"].to!string;
      int _w = 0;
      try { _w = m["width"].to!int; } catch (Exception) {}
      string _width = (_w > 0)
        ? ("width: " ~ min(_w, 440).to!string ~ "pt")
        : "width: 100%";
      string _rest = m["post"].to!string;
      string _alt;
      if (auto a = _rest.matchFirst(rgx.inline_image_alt)) {
        _alt  = a["alt"].to!string;
        _rest = a.post.to!string;
      }
      string _img = "#image(" ~ str("image/" ~ _file) ~ ", " ~ _width;
      if (_alt.length > 0) { _img ~= ", alt: " ~ str(_alt); }
      _img ~= ")";
      return m["pre"].to!string ~ keep(_img) ~ _rest;
    })(txt, rgx.inline_image);
  }
  /+ ↓ the font faces, as typst functions rather than typst markup: a function
       call cannot be broken by the character beside it, and the markup form can
  +/
  string fontFace(string txt) {
    string wrapWith(string fn) {
      return keep("#" ~ fn ~ "[") ~ "$1" ~ keep("]");
    }
    return txt
      .replaceAll(rgx.inline_emphasis,    wrapWith("emph"))
      .replaceAll(rgx.inline_bold,        wrapWith("strong"))
      .replaceAll(rgx.inline_italics,     wrapWith("emph"))
      .replaceAll(rgx.inline_underscore,  wrapWith("underline"))
      .replaceAll(rgx.inline_superscript, wrapWith("super"))
      .replaceAll(rgx.inline_subscript,   wrapWith("sub"))
      .replaceAll(rgx.inline_mono,        wrapWith("mono"))
      .replaceAll(rgx.inline_strike,      wrapWith("strike"))
      .replaceAll(rgx.inline_insert,      wrapWith("underline"))
      .replaceAll(rgx.inline_cite,        wrapWith("emph"));
  }
  /+ ↓ the line breaks the markup asked for. Not inside code, whose text is
       preserved as it stands.
  +/
  string breaks(O)(string txt, const O obj) {
    if (obj.metainfo.is_a == "code") { return txt; }
    string _br = keep("#linebreak()");
    return txt
      .replaceAll(rgx.br_line,        _br)
      .replaceAll(rgx.br_line_inline, _br)
      .replaceAll(rgx.br_line_spaced, _br ~ _br)
      .replaceAll(rgx.line_break,     _br)
      .replaceAll(rgx.nbsp_char,      " ");
  }
  /+ ↓ the line-leading markers of a grouped object: an indent as a measurement,
       a hanging indent's inner level, a bullet.
  +/
  string groupIndents(O)(string txt, const O obj) {
    if (obj.metainfo.is_a != "group" && obj.metainfo.is_a != "block") { return txt; }
    string pad(string n) { return keep("#h(" ~ n ~ " * 12pt)"); }
    return txt
      .replaceAll(rgx.grouped_para_indent_hang, pad("$2"))
      .replaceAll(rgx.grouped_para_bullet_indent, pad("$1") ~ keep("●") ~ " ")
      .replaceAll(rgx.grouped_para_bullet, keep("●") ~ " ")
      .replaceAll(rgx.grouped_para_indent, pad("$1"));
  }
  /+ ↓ a link. An internal target becomes a link to a label, and only when the
       label will exist: typst refuses to compile a link to one that will not,
       which is the check and so also the constraint.
  +/
  string links(O)(string txt, const O obj, bool[string] targets) {
    auto _stow = obj.stow.link;
    txt = replaceAll!((m) {
      size_t _num = m["num"].to!size_t;
      string _url = (_num < _stow.length) ? _stow[_num].to!string : "";
      return m["linked_text"].to!string ~ "┤" ~ _url ~ "├";
    })(txt, rgx.inline_link_number_only);
    txt = txt.replaceAll(rgx.inline_link_empty, "$1");
    txt = replaceAll!((m) {
      string _text = escape(fontFace(m.captures[1].to!string));
      string _target = m.captures[2].to!string;
      /+ ↓ an internal link to another segment: this is one document, so the
           segment is this document and only the fragment matters
      +/
      if (auto im = _target.matchFirst(rgx.inline_link_seg_and_hash)) {
        _target = "#" ~ im["hash"].to!string;
      }
      if (_target.length > 1 && _target[0] == '#') {
        string _l = labelOf(_target[1 .. $]);
        return keep((_l in targets)
          ? "#link(label(" ~ str(_l) ~ "))[" ~ unprotect(_text) ~ "]"
          : unprotect(_text));
      }
      if (_target.length == 0) { return keep(unprotect(_text)); }
      return keep("#link(" ~ str(_target) ~ ")[" ~ unprotect(_text) ~ "]");
    })(txt, rgx.inline_link);
    /+ ↓ whatever marker is left where a link was not one +/
    return txt.replaceAll(rgx.mark_internal_site_lnk, "");
  }
  /+ ↓ a note becomes a footnote at the point that refers to it, carrying the
       document's own number rather than typst's count, and a way back to the
       object it came from.
       .
       Object to note is the footnote mark, note to object is one link, and
       typst puts the note at the foot of the page the reference is on.
  +/
  string notes(O)(string txt, const O obj, bool[string] targets) {
    string _back;
    if (obj.metainfo.ocn != 0) {
      string _l = labelOf(obj.metainfo.ocn.to!string);
      if (_l in targets) {
        _back = " #link(label(" ~ str(_l) ~ "))[#sym.arrow.l.hook]";
      }
    }
    return replaceAll!((m) {
      string _mark = m["num"].to!string;
      string _note = m["note"].to!string;
      /+ ↓ the note's own text is content in its own right, so it goes through
           the same pipeline - links, faces, escaping - and nothing else
      +/
      string _body = escape(breaks!()(fontFace(
        links!()(images(_note), obj, targets)), obj));
      return keep("#footnote(numbering: _ => " ~ str(_mark) ~ ")["
        ~ unprotect(_body) ~ _back ~ "]");
    })(txt, rgx.inline_notes_al_all_note);
  }
  /+ ↓ an anchor the markup placed in the text +/
  string inlineAnchors(string txt, bool[string] targets, ref bool[string] placed) {
    string[] _names;
    foreach (m; txt.matchAll(rgx.inline_link_anchor)) {
      _names ~= m["anchor"].to!string;
    }
    foreach (name; _names) {
      string _l = labelOf(name);
      bool _place = (_l in targets) && !(_l in placed);
      if (_place) { placed[_l] = true; }
      txt = txt.replaceFirst(rgx.inline_link_anchor,
        _place ? keep("#anchor(" ~ str(_l) ~ ")") : "");
    }
    return txt;
  }
  /+ ↓ the named targets an object carries, as labels with no output +/
  string anchors(O)(const O obj, bool[string] targets, ref bool[string] placed) {
    auto _out = appender!string;
    string _own = (obj.metainfo.ocn == 0)
      ? "" : labelOf(obj.metainfo.ocn.to!string);
    void put(string name) {
      if (name.length == 0) { return; }
      string _l = labelOf(name);
      /+ ↓ the ocn's own label is placed on the number in the margin +/
      if (_l == _own || (_l in placed)) { return; }
      placed[_l] = true;
      _out ~= "#anchor(" ~ str(_l) ~ ")";
    }
    put(obj.metainfo.identifier.to!string);
    put(obj.tags.heading_lev_anchor_tag.to!string);
    foreach (at; obj.tags.anchor_tags) { put(at.to!string); }
    return _out.data;
  }
  /+ ↓ the object's text as typst content +/
  string inlineText(O)(
    string txt, const O obj, bool[string] targets, ref bool[string] placed
  ) {
    txt = notes!()(txt, obj, targets);
    txt = images(txt);
    txt = links!()(txt, obj, targets);
    txt = fontFace(txt);
    txt = inlineAnchors(txt, targets, placed);
    txt = groupIndents!()(txt, obj);
    txt = breaks!()(txt, obj);
    txt = escape(txt);
    return unprotect(txt);
  }
  /+ ↓ eight markup levels, eight heading levels. typst has no six-level
       ceiling, so the document's own depth goes through unchanged - which the
       html output cannot do.
  +/
  int headingLevel(string level) {
    switch (level) {
    case "A": return 1;
    case "B": return 2;
    case "C": return 3;
    case "D": return 4;
    case "1": return 5;
    case "2": return 6;
    case "3": return 7;
    case "4": return 8;
    default:  return 1;
    }
  }
  /+ ↓ an object in its block, with its number in the margin when it has one +/
  string wrap(O)(const O obj, string content) {
    return (obj.metainfo.ocn == 0)
      ? "#obj[" ~ content ~ "]"
      : "#ocn(" ~ obj.metainfo.ocn.to!string ~ ")[" ~ content ~ "]";
  }
  /+ ↓ the object number a table of contents entry points at, where it points at
       one. An entry may name a section by its anchor instead, and then there is
       no number to show.
  +/
  string tocTarget(O)(const O obj, bool[string] targets) {
    foreach (m; obj.text.matchAll(rgx.any_internal_target)) {
      string _frag = m["frag"].to!string;
      if (_frag.length == 0) { continue; }
      bool _digits = true;
      foreach (c; _frag) { if (c < '0' || c > '9') { _digits = false; break; } }
      if (_digits && (labelOf(_frag) in targets)) { return _frag; }
    }
    return "";
  }
  /+ ↓ whether a table of contents entry points at anything this writer renders.
       .
       A table of contents names what is in the document, so an entry whose only
       target is gone is not an entry: the endnotes are footnotes here, so the
       section they had is gone and so is the line that pointed at it. Decided
       from the target and not from the rendering, because a nested link - a
       link inside a link, which the flat markers cannot express - leaves an
       entry with no link to show and it is still an entry.
  +/
  bool pointsAtSomething(O)(const O obj, bool[string] targets) {
    bool _saw = false;
    foreach (m; obj.text.matchAll(rgx.any_internal_target)) {
      _saw = true;
      if (labelOf(m["frag"].to!string) in targets) { return true; }
    }
    return !_saw;
  }
  /+ ↓ one object +/
  string object(O,M)(
    const O obj, M doc_matters, bool[string] targets, ref bool[string] placed
  ) {
    /+ ↓ an object with no text of its own writes nothing. A poem is such an
         object: a container that closes a run of verses and carries the same
         object number as the last of them.
    +/
    if (obj.text.strip.length == 0) { return ""; }
    string _body;
    string _anchors;
    switch (obj.metainfo.is_a) {
    case "heading":
      /+ ↓ a dummy heading is generated furniture whose text repeats the heading
           above it; its anchors are what the table of contents wants
      +/
      if (obj.metainfo.dummy_heading) {
        _anchors = anchors!()(obj, targets, placed);
        return (_anchors.length > 0) ? _anchors ~ newlines : "";
      }
      _body = inlineText!()(obj.text, obj, targets, placed);
      if (_body.strip.length == 0) { return ""; }
      _anchors = anchors!()(obj, targets, placed);
      return wrap!()(obj, "#heading(level: "
        ~ headingLevel(obj.metainfo.marked_up_level.to!string).to!string
        ~ ")[" ~ _anchors ~ _body ~ "]") ~ newlines;
    case "toc":
      if (obj.metainfo.dummy_heading) { return ""; }
      _body = inlineText!()(obj.text, obj, targets, placed);
      if (_body.strip.length == 0) { return ""; }
      if (!pointsAtSomething!()(obj, targets)) { return ""; }
      /+ ↓ the entry's indent, the heading as a link, a dotted leader, and the
           object number as the locator.
           .
           Not a page number, and not typst's #outline(), which would make one.
           A page number is a fact about this typesetting: change the paper, the
           orientation, the type size, the leading or the margins and every
           number in the table moves. The object number does not. So the reader
           is given the reference that holds across every output of this
           document and across every setting of this one, and it is the same
           number the margin carries and the same number a citation uses.
      +/
      int _indent = (obj.attrib.indent_hang > 1) ? (obj.attrib.indent_hang - 1) : 0;
      string _locator;
      string _n = tocTarget!()(obj, targets);
      if (_n.length > 0) {
        _locator = " #box(width: 1fr, inset: (x: 3pt), repeat[.]) "
          ~ "#link(label(" ~ str(labelOf(_n)) ~ "))[" ~ _n ~ "]";
      }
      return "#block(inset: (left: " ~ (_indent * 8).to!string ~ "pt))["
        ~ _body ~ _locator ~ "]" ~ newlines;
    case "table":
      return table!()(obj, doc_matters, targets, placed);
    case "code":
      /+ ↓ nothing inside a code block is interpreted, which is what a raw block
           is for. The one substitution is the non-breaking space marker spine
           uses inside code to hold a leading indent.
      +/
      string _lang = (obj.metainfo.syntax.length > 0)
        ? ("lang: " ~ str(obj.metainfo.syntax.to!string) ~ ", ") : "";
      string _code = obj.text.replaceAll(rgx.nbsp_char, " ");
      return wrap!()(obj, "#raw(block: true, " ~ _lang ~ str(_code) ~ ")") ~ newlines;
    case "quote":
      _body = inlineText!()(obj.text, obj, targets, placed);
      if (_body.strip.length == 0) { return ""; }
      _anchors = anchors!()(obj, targets, placed);
      return wrap!()(obj, "#block(inset: (left: 18pt, right: 18pt))["
        ~ _anchors ~ _body ~ "]") ~ newlines;
    case "group": case "block":
      _body = inlineText!()(obj.text, obj, targets, placed);
      if (_body.strip.length == 0) { return ""; }
      _anchors = anchors!()(obj, targets, placed);
      return wrap!()(obj, "#block[" ~ _anchors ~ _body ~ "]") ~ newlines;
    case "verse": case "poem":
      /+ ↓ every line break kept, and no justification, because a line of verse
           ends where the poet ended it
      +/
      _body = inlineText!()(obj.text, obj, targets, placed);
      if (_body.strip.length == 0) { return ""; }
      _anchors = anchors!()(obj, targets, placed);
      return wrap!()(obj, "#block(inset: (left: 12pt))[#set par(justify: false);"
        ~ _anchors ~ _body ~ "]") ~ newlines;
    case "para": case "blurb": case "glossary": case "bibliography":
    case "bookindex":
      if (obj.metainfo.dummy_heading) { return ""; }
      _body = inlineText!()(obj.text, obj, targets, placed);
      if (_body.strip.length == 0) { return ""; }
      _anchors = anchors!()(obj, targets, placed);
      string _bullet = obj.attrib.bullet ? "●~" : "";
      string _content = (obj.attrib.indent_base > 0)
        ? "#block(inset: (left: " ~ (obj.attrib.indent_base * 12).to!string
          ~ "pt))[" ~ _anchors ~ _bullet ~ _body ~ "]"
        : _anchors ~ _bullet ~ _body;
      return wrap!()(obj, _content) ~ newlines;
    default:
      return "";
    }
  }
  /+ ↓ a table. The declared column widths become fractions, which is what a
       width relative to the others means, and a header row becomes one.
  +/
  string table(O,M)(
    const O obj, M doc_matters, bool[string] targets, ref bool[string] placed
  ) {
    auto _rows = obj.text.split(rgx.table_delimiter_row);
    int _cols = max(obj.table.number_of_columns.to!int, 1);
    auto _out = appender!string;
    _out ~= "#table(\n  columns: (";
    if (obj.table.column_widths.length > 0) {
      foreach (i, w; obj.table.column_widths) {
        _out ~= (i > 0 ? ", " : "") ~ w.to!string ~ "fr";
      }
    } else {
      foreach (i; 0 .. _cols) { _out ~= (i > 0 ? ", " : "") ~ "1fr"; }
    }
    _out ~= "),\n  align: (";
    foreach (i; 0 .. _cols) {
      string _a = (i < obj.table.column_aligns.length)
        ? obj.table.column_aligns[i].to!string : "l";
      _out ~= (i > 0 ? ", " : "")
        ~ ((_a == "c") ? "center" : (_a == "r") ? "right" : "left");
    }
    _out ~= "),\n  stroke: 0.4pt + luma(180),\n  inset: 4pt,\n";
    bool _first = true;
    foreach (row; _rows) {
      if (row.strip.length == 0) { continue; }
      auto _cells = row.split(rgx.table_delimiter_col);
      foreach (cell; _cells) {
        string _c = inlineText!()(cell, obj, targets, placed);
        _out ~= (_first && obj.table.heading)
          ? "  table.cell(fill: luma(240))[#strong[" ~ _c ~ "]],\n"
          : "  [" ~ _c ~ "],\n";
      }
      _first = false;
    }
    _out ~= ")";
    return wrap!()(obj, _out.data) ~ newlines;
  }
  /+ ↓ the document's objects +/
  string typstBody(D,M)(const D doc_abstraction, M doc_matters) {
    auto _targets = definedTargets!()(doc_abstraction, doc_matters);
    auto _placed  = ocnLabels!()(doc_abstraction, doc_matters);
    auto _out = appender!string;
    foreach (part; typstSections!()(doc_matters)) {
      foreach (obj; doc_abstraction[part]) {
        if (obj.metainfo.is_of_type == "comment") { continue; }
        _out ~= guardBlockStarts(object!()(obj, doc_matters, _targets, _placed));
      }
    }
    return _out.data;
  }
  void outputTypst(D,M)(
    const D  doc_abstraction,
          M  doc_matters,
  ) {
    auto pth_typst = spinePathsTypst(doc_matters);
    try {
      if (!exists(pth_typst.base_pth)) { (pth_typst.base_pth).mkdirRecurse; }
    } catch (ErrnoException ex) {
    }
    if (doc_matters.opt.action.vox_gt_1) {
      writeln(" ", pth_typst.typst_file);
    }
    {
      auto f = File(pth_typst.typst_file, "w");
      f.write(typstHead!()(doc_matters));
      f.write(typstBody!()(doc_abstraction, doc_matters));
    }
    /+ ↓ the images the document names, beside the .typ, because a .typ names
         its images by path and does not carry them
    +/
    if (doc_matters.srcs.image_list.length > 0) {
      try {
        if (!exists(pth_typst.images)) { (pth_typst.images).mkdirRecurse; }
        foreach (image; doc_matters.srcs.image_list) {
          string _in  = doc_matters.src.image_dir_path ~ "/" ~ image;
          string _out = pth_typst.images ~ "/" ~ image;
          if (exists(_in) && !exists(_out)) { _in.copy(_out); }
        }
      } catch (Exception ex) {
      }
    }
  }
}
#+END_SRC

*** template typst pdf output

#+NAME: Template_typst_pdf_output
#+HEADER: :noweb yes
#+BEGIN_SRC d
/+ ↓ the pdf: typst compiles the .typ (just) written by the writer.
     .
     This is the first time spine invokes a typesetter itself. By contrast sisu
     (ruby) invoked xelatex after producing latex, but for spine this was
     considered to be overly complicated and too slow. Spine's latex build
     writes a .tex and leaves xelatex to the caller; --pdf does not, because one
     static binary is a different proposition from a 6.6 GB texlive and because
     a pdf is what was asked for.
     .
     One .typ, one pdf per paper size spine is configured for, the paper passed
     in rather than compiled in:
       typst compile --input paper=a4 --input orientation=portrait
     .
     If typst is not available on the machine this says so, naming the binary,
     and the run continues with the .typ being written either way (as with latex
     output it can be compiled later).
+/
template outputTypstPdf() {
  import sisudoc.outputs.io_out;
  import sisudoc.outputs.io_out.paths_output;
  import std.conv : to;
  import std.file;
  import std.process;
  import std.stdio;
  enum typst_binary = "typst";
  @trusted bool typstOnPath() {
    try {
      auto probe = execute([typst_binary, "--version"]);
      return probe.status == 0;
    } catch (Exception ex) {
      return false;
    }
  }
  /+ ↓ spine`s paper names and typst`s are not the same vocabulary. Mapped
       rather than passed through, and a paper typst does not have is named and
       skipped.
  +/
  string typstPaper(string spine_paper) {
    switch (spine_paper) {
    case "a3":     return "a3";
    case "a4":     return "a4";
    case "a5":     return "a5";
    case "b4":     return "iso-b4";
    case "b5":     return "iso-b5";
    case "letter": return "us-letter";
    case "legal":  return "us-legal";
    default:       return "";
    }
  }
  @trusted void outputTypstPdf(M)(M doc_matters) {
    auto pth_typst = spinePathsTypst(doc_matters);
    if (!exists(pth_typst.typst_file)) {
      writeln("spine: --pdf found no ", pth_typst.typst_file, " to compile");
      return;
    }
    if (!typstOnPath) {
      writeln("spine: --pdf needs the \"", typst_binary,
        "\" binary on the path and did not find it; the .typ is written:");
      writeln("  ", pth_typst.typst_file);
      writeln("  typst compile --root ", pth_typst.base_pth, " <that file> <a pdf>");
      return;
    }
    try {
      if (!exists(pth_typst.pdf_pth)) { (pth_typst.pdf_pth).mkdirRecurse; }
    } catch (Exception ex) {
    }
    foreach (paper_size_orientation; doc_matters.conf_make_meta.conf.set_papersize) {
      string _paper = paper_size_orientation;
      string _orientation = "portrait";
      foreach (i, c; paper_size_orientation) {
        if (c == '.') {
          _paper = paper_size_orientation[0 .. i];
          _orientation = paper_size_orientation[(i + 1) .. $];
          break;
        }
      }
      string _typst_paper = typstPaper(_paper);
      if (_typst_paper.length == 0) {
        writeln("spine: typst has no paper named \"", _paper,
          "\"; skipping ", paper_size_orientation);
        continue;
      }
      string _pdf = pth_typst.pdf_file_with_path(paper_size_orientation);
      auto _cmd = [
        typst_binary, "compile",
        "--root", pth_typst.base_pth,
        "--input", "paper=" ~ _typst_paper,
        "--input", "orientation=" ~ _orientation,
        pth_typst.typst_file,
        _pdf,
      ];
      if (doc_matters.opt.action.vox_gt_1) { writeln(" ", _pdf); }
      auto _run = execute(_cmd);
      if (_run.status != 0) {
        writeln("spine: typst could not compile ", pth_typst.typst_file,
          " for ", paper_size_orientation);
        writeln(_run.output);
      }
    }
  }
}
#+END_SRC

* org includes
** project version

#+NAME: spine_version
#+HEADER: :noweb yes
#+BEGIN_SRC emacs-lisp
<<./sisudoc_spine_version_info_and_doc_header_including_copyright_and_license.org:spine_project_version()>>
#+END_SRC

** year

#+NAME: year
#+HEADER: :noweb yes
#+BEGIN_SRC emacs-lisp
<<./sisudoc_spine_version_info_and_doc_header_including_copyright_and_license.org:year()>>
#+END_SRC

** document header including copyright & license

#+NAME: doc_header_including_copyright_and_license
#+HEADER: :noweb yes
#+BEGIN_SRC emacs-lisp
<<./sisudoc_spine_version_info_and_doc_header_including_copyright_and_license.org:spine_doc_header_including_copyright_and_license()>>
#+END_SRC

* __END__