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
|
module write.typst;
/+ ↓ the typst writer: a document as typst markup, for a pdf.
.
The fourth format and the first that is not itself the output: what this
writes is a .typ file, and `typst compile` turns it into a pdf. That is
deliberate. A writer's job is model -> bytes; making a pdf is a
typesetter's, and typst is a better typesetter than this file would be.
.
The judge is *typst itself*, and it is a strict one for the reason that
matters here: **a link to a label that does not exist is a compile
error.** So "it compiles" means "every internal link resolves", which in
the html writer took a checker of its own.
.
What this demonstrates, and why it was worth doing last: the pdf is the
format spine leans hardest on an external toolchain for, and the one
whose hard parts are the ones an object-centric document cares about -
*the object number in the margin*, and *a link from an object to its
note and back*. Both are a few lines here.
.
Set against spine's xelatex path, measured rather than asserted: no
preamble, no .sty files, no second pass, a toolchain of 108 MB where
texlive-combined-full is 6.6 GB (nix closures), a tagged pdf where
spine's says "Tagged: no", and **56,990 CJK characters on the page where
spine's Japanese pdf has none at all** - 92 pages of Latin fragments and
white space. The pdf is byte identical run to run.
+/
import std.algorithm : canFind, endsWith, max, min, startsWith;
import std.array : appender, array, join, replace;
import std.array : asplit = split;
import std.conv : to;
import std.format : format;
import std.regex : matchAll, matchFirst, replaceAll, split;
import std.string : strip, toLower;
import ocda.model;
import write.rgx;
static Rgx rgx_t;
/+ ↓ the labels already placed in this document.
.
A name may be claimed twice: a book index sub-entry ("existing
information") occurs under more than one heading, and each object carries
the same anchor. A label defined twice is ambiguous, so the first
placement keeps it and the rest are dropped - which is what a link to it
would have resolved to anyway.
+/
private bool[string] placed_labels;
enum generator_name = "spine";
/+ ↓ the document as typst markup +/
string writeTypst(ref OcdaDocument doc) {
placed_labels = null;
auto targets = definedTargets(doc);
/+ ↓ an OCN label is placed by `ocn` on the number in the margin, always and
for every numbered object, so it is claimed before anything else. An
anchor or an identifier that happens to carry the same name is then
dropped rather than defining the label twice, which typst rejects:
free_culture has a heading whose identifier is "2" and an object whose
OCN is 2. +/
foreach (name; typstSections(doc)) {
auto sec = doc.sectionByName(name);
if (sec is null) { continue; }
foreach (obj; *sec) {
if (obj.renders && obj.ocn != 0) { placed_labels[labelOf(obj.ocn.to!string)] = true; }
}
}
auto out_ = appender!string;
out_ ~= preamble(doc);
foreach (name; typstSections(doc)) {
foreach (obj; *doc.sectionByName(name)) {
if (!obj.renders) { continue; }
out_ ~= guardBlockStarts(objectOut(obj, doc, targets));
}
}
return out_.data;
}
/+ ↓ which sections, in which order.
.
head, toc, body, then glossary, bibliography, bookindex and blurb where
each has more than one object. *Not 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.
.
Spine's latex makes the same choice and leaves the heading behind: its
.tex ends with the word "Endnotes" as bare text, outside any sectioning
command, with nothing under it and then \end{document}. Dropping the
heading too is the same decision carried through. It costs the OCN that
spine gives that generated heading, which is why the sweep names it
rather than passing over it.
+/
string[] typstSections(ref OcdaDocument doc) {
string[] seq = ["head", "toc", "body"];
foreach (name; ["glossary", "bibliography", "bookindex", "blurb"]) {
auto sec = doc.sectionByName(name);
if (sec !is null && (*sec).length > 1) { seq ~= name; }
}
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. Same census the epub
needed, for the same reason and with a harder consequence: there the
reward was a valid publication, here it is a document at all.
+/
bool[string] definedTargets(ref OcdaDocument doc) {
bool[string] out_;
foreach (name; typstSections(doc)) {
auto sec = doc.sectionByName(name);
if (sec is null) { continue; }
foreach (obj; *sec) {
if (!obj.renders) { continue; }
if (obj.ocn != 0) { out_[labelOf(obj.ocn.to!string)] = true; }
if (obj.identifier.length > 0) { out_[labelOf(obj.identifier)] = true; }
if (obj.heading_lev_anchor.length > 0) {
out_[labelOf(obj.heading_lev_anchor)] = true;
}
foreach (a; obj.anchor_tags) { out_[labelOf(a)] = true; }
foreach (m; obj.text.matchAll(rgx_t.inline_link_anchor)) {
out_[labelOf(m["anchor"].to!string)] = true;
}
}
}
return out_;
}
/+ ↓ a label name typst will accept, from a name the markup chose.
Prefixed, because a label that begins with a digit is asking for
trouble and every ocn does.
.
Codepoints, not bytes: a book index entry named ‟CC as movement and
“machine”, and” has three-byte quotation marks in it, and walking bytes
turned each into three underscores where the Gleam writer made one. The
two writers must agree, and one underscore per character is the sensible
of the two.
+/
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_;
}
/+ ↓ the preamble: page, text, and the two functions the body leans on +/
private string preamble(ref OcdaDocument doc) {
string lang = ("language" in doc.source) ? doc.source["language"] : "en";
string region;
auto parts = lang.replace("_", "-").asplit("-");
lang = parts[0];
if (parts.length > 1) { region = parts[1]; }
string title = doc.meta("title.full");
if (title.length == 0) { title = doc.meta("title.main"); }
auto out_ = appender!string;
out_ ~= "// " ~ generator_name ~ ": the object-centric document abstraction,"
~ " written as typst\n";
out_ ~= "// Generated from the abstraction. Compile with: typst compile <this file>\n\n";
/+ ↓ the pdf's own metadata, from the document's own metadata. date is the
document's, never the clock's: a document typeset twice is one document
+/
out_ ~= "#set document(\n title: " ~ str(title) ~ ",\n";
if (doc.meta("creator.author").length > 0) {
out_ ~= " author: " ~ str(doc.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: \"a4\",\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.
.
This is the thing a pdf of an object-centric document has to get
right, and in typst it is one `place` with a negative offset. Spine's
latex uses \marginpar, which fails inside a float or a footnote and
migrates to the next page when it will not fit; `place` is a
coordinate inside a block and does neither.
+/
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";
/+ ↓ a named target with no output of its own +/
/+ ↓ 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, and hands back "expected string, found
content". A code block is a different thing and does use raw. +/
out_ ~= "#let mono(body) = text(font: (\"DejaVu Sans Mono\",), body)\n";
out_ ~= "#let anchor(name) = [#box(width: 0pt)#label(name)]\n\n";
return out_.data;
}
/+ ↓ one object +/
private string objectOut(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
switch (obj.is_a) {
case "heading": return heading(obj, doc, targets);
case "toc": return tocEntry(obj, doc, targets);
case "para": case "blurb":
return para(obj, doc, targets);
case "glossary": case "bibliography":
return para(obj, doc, targets);
case "bookindex": return para(obj, doc, targets);
case "quote": return quote(obj, doc, targets);
case "group": case "block":
return group(obj, doc, targets);
case "verse": case "poem":
return verse(obj, doc, targets);
case "code": return code(obj);
case "table": return table(obj, doc, targets);
default: return "";
}
}
/+ ↓ eight markup levels, eight heading levels.
typst has no six-level ceiling, so the document's own depth goes through
unchanged - which the html writer could not do and had to say so with
aria-level.
+/
private 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;
}
}
private string heading(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
/+ ↓ a dummy heading is generated furniture whose text repeats the heading
above it; its anchors are what the table of contents wants +/
if (obj.dummy_heading) {
string a = anchors(obj, targets);
return (a.length > 0) ? a ~ "\n\n" : "";
}
string body_ = inline(obj, doc, targets);
if (body_.strip.length == 0) { return ""; }
string h = "#heading(level: " ~ headingLevel(obj.heading_level).to!string
~ ")[" ~ anchors(obj, targets) ~ body_ ~ "]";
return wrap(obj, h) ~ "\n\n";
}
/+ ↓ a table of contents entry: its 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.
.
The text and the number are both links to the object.
+/
private string tocEntry(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
if (obj.dummy_heading) { return ""; }
string body_ = inline(obj, doc, targets);
if (body_.strip.length == 0) { return ""; }
if (!pointsAtSomething(obj, targets)) { return ""; }
int indent = (obj.indent_hang > 1) ? (obj.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 ~ "]\n\n";
}
/+ ↓ 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.
+/
private string tocTarget(ref const OcdaObject obj, bool[string] targets) {
foreach (m; obj.text.matchAll(rgx_t.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*, not from the rendered text. An earlier version
dropped any entry that came out without a link in it, and so dropped
gpl3`s copyright line, whose markup nests one link inside another - which
the flat markers cannot express, so no writer resolves it and the entry
has no link to show.
+/
private bool pointsAtSomething(ref const OcdaObject obj, bool[string] targets) {
bool saw = false;
foreach (m; obj.text.matchAll(rgx_t.any_internal_target)) {
saw = true;
if (labelOf(m["frag"].to!string) in targets) { return true; }
}
return !saw;
}
private string para(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
if (obj.dummy_heading) { return ""; }
string body_ = inline(obj, doc, targets);
if (body_.strip.length == 0) { return ""; }
string bullet = obj.bullet ? "●~" : "";
string inset;
if (obj.indent_base > 0) {
inset = "#block(inset: (left: " ~ (obj.indent_base * 12).to!string ~ "pt))[";
}
string content = inset.length > 0
? inset ~ anchors(obj, targets) ~ bullet ~ body_ ~ "]"
: anchors(obj, targets) ~ bullet ~ body_;
return wrap(obj, content) ~ "\n\n";
}
private string quote(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
string body_ = inline(obj, doc, targets);
if (body_.strip.length == 0) { return ""; }
return wrap(obj, "#block(inset: (left: 18pt, right: 18pt))["
~ anchors(obj, targets) ~ body_ ~ "]") ~ "\n\n";
}
/+ ↓ group and block: one object whose text holds its own lines +/
private string group(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
string body_ = inline(obj, doc, targets);
if (body_.strip.length == 0) { return ""; }
return wrap(obj, "#block[" ~ anchors(obj, targets) ~ body_ ~ "]") ~ "\n\n";
}
/+ ↓ verse: every line break kept, and no justification, because a line of
verse ends where the poet ended it +/
private string verse(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
string body_ = inline(obj, doc, targets);
if (body_.strip.length == 0) { return ""; }
return wrap(obj, "#block(inset: (left: 12pt))[#set par(justify: false);"
~ anchors(obj, targets) ~ body_ ~ "]") ~ "\n\n";
}
/+ ↓ code: nothing inside it 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. The html writer turns it into a space, so
this does too; left as it stands it is a lozenge in the middle of a
program.
+/
private string code(ref const OcdaObject obj) {
string lang = obj.code_syntax.length > 0
? ("lang: " ~ str(obj.code_syntax) ~ ", ") : "";
string txt = obj.text.replaceAll(rgx_t.nbsp_char, " ");
return wrap(obj, "#raw(block: true, " ~ lang ~ str(txt) ~ ")") ~ "\n\n";
}
/+ ↓ a table. The declared column widths become fractions, which is what a
width relative to the others means, and a header row becomes one.
+/
private string table(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
auto rows = obj.text.split(rgx_t.table_delimiter_row);
auto widths = obj.table_widths.asplit(" ");
auto aligns = obj.table_aligns.asplit(" ");
int cols = obj.table_cols;
auto out_ = appender!string;
out_ ~= "#table(\n columns: (";
if (widths.length > 0 && widths[0].length > 0) {
foreach (i, w; widths) {
out_ ~= (i > 0 ? ", " : "") ~ w ~ "fr";
}
} else {
foreach (i; 0 .. max(cols, 1)) { out_ ~= (i > 0 ? ", " : "") ~ "1fr"; }
}
out_ ~= "),\n align: (";
foreach (i; 0 .. max(cols, 1)) {
string a = (i < aligns.length) ? aligns[i] : "l";
out_ ~= (i > 0 ? ", " : "") ~ alignName(a);
}
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_t.table_delimiter_col);
foreach (cell; cells) {
string c = inlineText(cell, obj, doc, targets);
out_ ~= (first && obj.table_header)
? " table.cell(fill: luma(240))[#strong[" ~ c ~ "]],\n"
: " [" ~ c ~ "],\n";
}
first = false;
}
out_ ~= ")";
return wrap(obj, out_.data) ~ "\n\n";
}
private string alignName(string a) {
switch (a) {
case "c": return "center";
case "r": return "right";
default: return "left";
}
}
/+ ↓ an object in its block, with its number in the margin when it has one +/
private string wrap(ref const OcdaObject obj, string content) {
return (obj.ocn == 0)
? "#obj[" ~ content ~ "]"
: "#ocn(" ~ obj.ocn.to!string ~ ")[" ~ content ~ "]";
}
/+ ↓ the named targets an object carries, as labels with no output +/
private string anchors(ref const OcdaObject obj, bool[string] targets) {
auto out_ = appender!string;
bool[string] done;
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, so it is
not repeated here +/
if (obj.ocn != 0 && l == labelOf(obj.ocn.to!string)) { return; }
if (l in done || l in placed_labels) { return; }
done[l] = true;
placed_labels[l] = true;
out_ ~= "#anchor(" ~ str(l) ~ ")";
}
put(obj.identifier);
put(obj.heading_lev_anchor);
foreach (a; obj.anchor_tags) { put(a); }
return out_.data;
}
/+ ↓ the object's text, as typst content +/
private string inline(ref const OcdaObject obj, ref OcdaDocument doc,
bool[string] targets) {
return inlineText(obj.text, obj, doc, targets);
}
private string inlineText(string txt, ref const OcdaObject obj,
ref OcdaDocument doc, bool[string] targets) {
txt = notes(txt, obj, doc, targets);
txt = images(txt, obj);
txt = links(txt, obj, targets);
txt = fontFace(txt);
txt = inlineAnchors(txt, targets);
txt = groupIndents(txt, obj);
txt = breaks(txt, obj);
txt = escape(txt);
txt = unprotect(txt);
return txt;
}
/+ ↓ the markers this writer replaces with typst, protected from the escaping
that follows.
.
The order is the awkward part of writing for a language whose markup has
its own special characters: the replacements *are* typst syntax and must
not be escaped, and the text around them must be. So each replacement is
wrapped in two characters that cannot occur in a document - the same
private-use pair throughout - and the escaping skips what lies between
them.
+/
private enum keep_open = "";
private enum keep_close = "";
private string keep(string typst_source) {
return keep_open ~ typst_source ~ keep_close;
}
/+ ↓ ... and at the end the protection comes off +/
private string unprotect(string txt) {
return txt.replace(keep_open, "").replace(keep_close, "");
}
/+ ↓ 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. A narrower rule kept failing, because 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.
.
So: "\", "#", "[", "]", "*", "_", "$", "@", "<", ">", "`", "~", "(",
")", ".", "/" and the quotation mark. A "." also begins an ellipsis and a
"/" a comment, both of which this covers; "--" is an en dash and is
caught by the doubled-dash case below. A document that says "--" means
"--".
.
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's text
alone; an odd number of quotation marks in a paragraph would otherwise
put it out by one for the rest of the object.
.
The protection nests, so it is counted and not a flag. A link built by
`links` is protected, and a line break inside its text is then protected
again by `breaks`; with a flag the inner close read as the end of the
outer run and the rest of a link got escaped, which cost the closing "]"
of every image caption that carried a line break.
+/
private 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 typst string literal +/
private 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;
}
/+ ↓ 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.
.
That is the whole of what 0.4 asked about: 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.
+/
private string notes(string txt, ref const OcdaObject obj,
ref OcdaDocument doc, bool[string] targets) {
string back;
if (obj.ocn != 0) {
string l = labelOf(obj.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), obj, targets)), obj));
return keep("#footnote(numbering: _ => " ~ str(mark) ~ ")["
~ unprotect(body_) ~ back ~ "]");
})(txt, rgx_t.inline_notes_al_all_note);
}
/+ ↓ 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.
+/
private string links(string txt, ref const OcdaObject 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] : "";
return m["linked_text"].to!string ~ "┤" ~ url ~ "├";
})(txt, rgx_t.inline_link_number_only);
txt = txt.replaceAll(rgx_t.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 (target.startsWith("¤")) {
auto im = target.matchFirst(rgx_t.internal_target);
if (!im.empty) { target = im["frag"].to!string; }
}
if (target.startsWith("#")) {
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_t.inline_link);
/+ what marker is left where a link was not one +/
return txt.replaceAll(rgx_t.mark_internal_site_lnk, "");
}
/+ ↓ 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. Uncapped it
overflows and typst says so; capped it fits and the aspect ratio is
typst's business.
+/
private string images(string txt, ref const OcdaObject obj) {
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_t.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 ~= ")";
/+ ↓ the opening link marker is kept, exactly 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. +/
return m["pre"].to!string ~ keep(img) ~ rest;
})(txt, rgx_t.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 +/
private string fontFace(string txt) {
string wrapWith(string fn) {
return keep("#" ~ fn ~ "[") ~ "$1" ~ keep("]");
}
return txt
.replaceAll(rgx_t.inline_emphasis, wrapWith("emph"))
.replaceAll(rgx_t.inline_bold, wrapWith("strong"))
.replaceAll(rgx_t.inline_italics, wrapWith("emph"))
.replaceAll(rgx_t.inline_underscore, wrapWith("underline"))
.replaceAll(rgx_t.inline_superscript, wrapWith("super"))
.replaceAll(rgx_t.inline_subscript, wrapWith("sub"))
.replaceAll(rgx_t.inline_mono, wrapWith("mono"))
.replaceAll(rgx_t.inline_strike, wrapWith("strike"))
.replaceAll(rgx_t.inline_insert, wrapWith("underline"))
.replaceAll(rgx_t.inline_cite, wrapWith("emph"));
}
/+ ↓ an anchor the markup placed in the text +/
private string inlineAnchors(string txt, bool[string] targets) {
return replaceAll!((m) {
string l = labelOf(m["anchor"].to!string);
if (l !in targets || l in placed_labels) { return ""; }
placed_labels[l] = true;
return keep("#anchor(" ~ str(l) ~ ")");
})(txt, rgx_t.inline_link_anchor);
}
/+ ↓ the line breaks the markup asked for. Not inside code, whose text is
preserved as it stands. +/
private string breaks(string txt, ref const OcdaObject obj) {
if (obj.is_a == "code") { return txt; }
string br = keep("#linebreak()");
return txt
.replaceAll(rgx_t.br_line, br)
.replaceAll(rgx_t.br_line_inline, br)
.replaceAll(rgx_t.br_line_spaced, br ~ br)
.replaceAll(rgx_t.line_break, br)
.replaceAll(rgx_t.nbsp_char, " ");
}
/+ ↓ the line-leading markers of a grouped object: an indent as a measurement,
a hanging indent's inner level, a bullet.
.
Spine writes runs of non-breaking spaces here, up to eighteen of them for
one line. A measurement says the same intent in a way a typesetter can
act on, and the author's text keeps its own spaces.
+/
private string groupIndents(string txt, ref const OcdaObject obj) {
if (obj.is_a != "group" && obj.is_a != "block") { return txt; }
string pad(string n) { return keep("#h(" ~ n ~ " * 12pt)"); }
return txt
.replaceAll(rgx_t.grouped_para_indent_hang, pad("$2"))
.replaceAll(rgx_t.grouped_para_bullet_at,
pad("$1") ~ keep("●") ~ " ")
.replaceAll(rgx_t.grouped_para_bullet, keep("●") ~ " ")
.replaceAll(rgx_t.grouped_para_indent, pad("$1"));
}
/+ ↓ a "- ", "+ " or "= " that begins a line or a content block.
.
typst reads those three as a list item, an enumeration item and a
heading, but only in that position, so they are guarded there rather
than escaped in every hyphen of the document. An enumeration also starts
at "1. ", which needs nothing: the escaper takes every "." already.
+/
private string guardBlockStarts(string s) {
/+ ↓ and not inside a string literal. A code object is written as
#raw(block: true, "..."), and a guard inserted in there is a backslash
in the middle of somebody's program rather than an escape: one sample
has "[+ " in a shell snippet, and the html writer shows no backslash.
+/
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;
}
|