summaryrefslogtreecommitdiff
path: root/lib/render/escape.ml
blob: ee9ce6cd3cfb6856b17040611a63059be33581cf (plain) (blame)
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
type flavour = Latex | Typst | Groff | Html | Xml | Ics | None_

let all = [ Latex; Typst; Groff; Html; Xml; Ics; None_ ]

let to_string = function
  | Latex -> "latex" | Typst -> "typst" | Groff -> "groff" | Html -> "html"
  | Xml -> "xml" | Ics -> "ics" | None_ -> "none"

let of_string = function
  | "latex" -> Some Latex | "typst" -> Some Typst | "groff" -> Some Groff | "html" -> Some Html
  | "xml" -> Some Xml | "ics" -> Some Ics | "none" -> Some None_
  | _ -> None

let of_extension = function
  | ".tex" -> Some Latex
  | ".typ" -> Some Typst
  | ".ms" | ".mom" | ".me" -> Some Groff
  | ".html" | ".htm" -> Some Html
  | ".xml" -> Some Xml
  | ".ics" -> Some Ics
  | ".md" | ".adoc" | ".txt" -> Some None_
  | _ -> None

(* Replace each character with its expansion, in ONE pass. A sequence of
   String.concat replacements would double-escape: "&" -> "\\&" then the
   backslash rule would rewrite the backslash it just introduced. *)
let expand f s =
  let b = Buffer.create (String.length s + 16) in
  String.iter (fun c -> Buffer.add_string b (f c)) s;
  Buffer.contents b

let latex = function
  | '\\' -> "\\textbackslash{}"
  | '{' -> "\\{" | '}' -> "\\}"
  | '$' -> "\\$" | '&' -> "\\&" | '#' -> "\\#"
  | '_' -> "\\_" | '%' -> "\\%"
  | '^' -> "\\textasciicircum{}"
  | '~' -> "\\textasciitilde{}"
  | c -> String.make 1 c

(* Typst markup metacharacters, verified live against the installed `typst`
   0.14.2 binary rather than assumed (probe: each character escaped,
   compiled, pdftotext'd back, confirmed literal; each also confirmed to
   do something ELSE when left bare -- '#' opens code mode, '*'/'_' toggle
   strong/emph, '$' opens math, '@' opens a reference (a bare "@word" is a
   hard COMPILE ERROR, not merely mangled output, if "word" resolves to no
   label), '<'/'>' can close around a bare word into label syntax that
   swallows it whole, '`' opens raw, '~' is a non-breaking space, and a run
   of two or three '-' becomes an en/em dash). Backslash-escapable in every
   case -- no character on this list needed a non-backslash workaround.
   '-' is escaped UNCONDITIONALLY, not only inside a detected run: this
   function has no lookahead (see [expand]'s own comment above), so it
   cannot tell "is this hyphen part of a run" one character at a time: a
   probe confirmed escaping every hyphen independently ("\-\-\-") still
   typesets as three literal hyphens, never collapsing to an em dash, so
   the single per-character rule is sufficient for runs of any length. *)
let typst = function
  | '\\' -> "\\\\"
  | '#' -> "\\#" | '*' -> "\\*" | '_' -> "\\_" | '$' -> "\\$"
  | '@' -> "\\@" | '<' -> "\\<" | '>' -> "\\>" | '`' -> "\\`"
  | '~' -> "\\~" | '-' -> "\\-"
  | c -> String.make 1 c

let html = function
  | '&' -> "&amp;" | '<' -> "&lt;" | '>' -> "&gt;"
  | '"' -> "&quot;" | '\'' -> "&#39;"
  | c -> String.make 1 c

let ics = function
  | '\\' -> "\\\\" | ';' -> "\\;" | ',' -> "\\,"
  | '\n' -> "\\n" | '\r' -> ""
  | c -> String.make 1 c

(* groff: a backslash starts an escape, and a '.' or '\'' in COLUMN ONE starts a
   request. \& is the zero-width non-printing character that defuses it. *)
let groff s =
  let escaped = expand (function '\\' -> "\\e" | c -> String.make 1 c) s in
  if String.length escaped > 0 && (escaped.[0] = '.' || escaped.[0] = '\'') then "\\&" ^ escaped
  else escaped

let apply flavour s =
  match flavour with
  | Latex -> expand latex s
  | Typst -> expand typst s
  | Groff -> groff s
  | Html | Xml -> expand html s
  | Ics -> expand ics s
  | None_ -> s

(* RFC 5545 section 3.1. A continuation byte is 0x80-0xBF; backing off to a
   non-continuation byte keeps every fold on a character boundary. *)
let fold_ics line =
  let n = String.length line in
  let b = Buffer.create (n + (n / 70) + 8) in
  let is_cont c = Char.code c land 0xC0 = 0x80 in
  let rec go pos first =
    let limit = if first then 75 else 74 (* the leading space costs one octet *) in
    if n - pos <= limit then (
      if not first then Buffer.add_char b ' ';
      Buffer.add_string b (String.sub line pos (n - pos));
      Buffer.add_string b "\r\n")
    else begin
      let cut = ref (pos + limit) in
      while !cut > pos && is_cont line.[!cut] do decr cut done;
      (* Valid UTF-8's longest continuation run is 3, so backoff finds a
         boundary within a few bytes. But this function must stay TOTAL on
         ARBITRARY octet strings, not only valid UTF-8: 74+ consecutive
         continuation bytes back `cut` all the way down to `pos`, which would
         yield a zero-length chunk and recurse on the identical position
         forever. When backoff finds no boundary inside the window, cut hard
         at the limit instead -- forward progress is then unconditional. *)
      if !cut = pos then cut := pos + limit;
      if not first then Buffer.add_char b ' ';
      Buffer.add_string b (String.sub line pos (!cut - pos));
      Buffer.add_string b "\r\n";
      go !cut false
    end
  in
  go 0 true;
  Buffer.contents b