archsight 0.4.3 → 0.5.0

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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +4 -0
  3. data/docs/configuration.md +5 -0
  4. data/docs/diagram.md +5 -0
  5. data/docs/index.md.erb +1 -0
  6. data/docs/pdf-export.md +118 -0
  7. data/lib/archsight/analysis/typst_renderer.rb +25 -78
  8. data/lib/archsight/cli.rb +37 -0
  9. data/lib/archsight/diagram/graph/attributes.rb +3 -1
  10. data/lib/archsight/diagram/graph/node.rb +18 -1
  11. data/lib/archsight/diagram/layout/expander.rb +75 -3
  12. data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
  13. data/lib/archsight/markdown.rb +17 -1
  14. data/lib/archsight/pdf/cache.rb +41 -0
  15. data/lib/archsight/pdf/compiler.rb +69 -0
  16. data/lib/archsight/pdf/dependency_graph.rb +127 -0
  17. data/lib/archsight/pdf/document.rb +227 -0
  18. data/lib/archsight/pdf/embeds.rb +134 -0
  19. data/lib/archsight/pdf/images.rb +94 -0
  20. data/lib/archsight/pdf/markdown.rb +217 -0
  21. data/lib/archsight/pdf/remote_image.rb +122 -0
  22. data/lib/archsight/pdf/resource_sheet.rb +129 -0
  23. data/lib/archsight/pdf/sample_document.rb +67 -0
  24. data/lib/archsight/pdf/tables.rb +72 -0
  25. data/lib/archsight/pdf/theme.rb +125 -0
  26. data/lib/archsight/pdf/typst/archsight.typ +152 -0
  27. data/lib/archsight/pdf/typst.rb +45 -0
  28. data/lib/archsight/pdf.rb +30 -0
  29. data/lib/archsight/resources/config.rb +147 -0
  30. data/lib/archsight/version.rb +1 -1
  31. data/lib/archsight/web/api/openapi/spec.yaml +230 -0
  32. data/lib/archsight/web/api/pdf_helpers.rb +46 -0
  33. data/lib/archsight/web/api/routes.rb +30 -0
  34. data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
  35. data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
  36. data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
  37. data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
  38. data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
  39. data/lib/archsight/web/public/vue/{InstanceRouter-CJSkGdBG.css → InstanceRouter-BCKbW-Wp.css} +1 -1
  40. data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
  41. data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
  42. data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
  43. data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
  44. data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
  45. data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
  46. data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
  47. data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
  48. data/lib/archsight/web/public/vue/{index-DMhBow5t.js → index-BF4zUlWZ.js} +2 -2
  49. data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
  50. data/lib/archsight/web/public/vue.html +2 -2
  51. metadata +35 -17
  52. data/lib/archsight/web/public/vue/DocPage-L0GzDFiV.js +0 -1
  53. data/lib/archsight/web/public/vue/EditorPage-B3iNbh1u.js +0 -35
  54. data/lib/archsight/web/public/vue/ErrorPage-Lro5vwhy.js +0 -2
  55. data/lib/archsight/web/public/vue/GraphView-BRKwxa--.js +0 -1
  56. data/lib/archsight/web/public/vue/HomePage-DKgI1DXr.js +0 -2
  57. data/lib/archsight/web/public/vue/InstanceRouter-CX6qxVTm.js +0 -1
  58. data/lib/archsight/web/public/vue/KindList-C556gf23.js +0 -1
  59. data/lib/archsight/web/public/vue/PageView-DAqHjL1x.js +0 -1
  60. data/lib/archsight/web/public/vue/QueryError-DvspMrsh.js +0 -1
  61. data/lib/archsight/web/public/vue/ResourceList-BZOYa68X.js +0 -2
  62. data/lib/archsight/web/public/vue/SearchResults-DaaOPG3t.js +0 -1
  63. data/lib/archsight/web/public/vue/WikiPage-Bfx92Iig.js +0 -3
  64. data/lib/archsight/web/public/vue/index-B_YGrWD8.css +0 -1
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../markdown"
4
+ require_relative "../helpers/macros"
5
+ require_relative "../jira_settings"
6
+ require_relative "typst"
7
+
8
+ module Archsight
9
+ module Pdf
10
+ # Markdown (the dialect of Archsight::Markdown) as Typst markup.
11
+ #
12
+ # What a document refers to is resolved by the caller through hooks, so this class knows neither the database
13
+ # nor the file system:
14
+ #
15
+ # - `image`: `(src, alt) -> String | nil`, Typst markup for an embedded image (nil: left out, the alt text is shown)
16
+ # - `diagram`: `(source) -> String`, Typst markup for an `asd` code block (default: the source as code)
17
+ # - `label`: `(target) -> String`, the text of a `[[Target]]` link without label (default: the target)
18
+ # - `embeds`: an object with `view_block(source)`, `requirements_block(source)` and `embed(reference)`, each
19
+ # returning Typst markup (never raising: what cannot be shown is a note). It makes the live content of a page, a
20
+ # ```view or ```requirements block and `![[Kind/Name]]` on a line of its own, part of the document. Without it
21
+ # those blocks are shown as written and an embed is its label.
22
+ class Markdown
23
+ include Typst
24
+
25
+ SYMBOLS = {
26
+ ndash: "–", mdash: "—", hellip: "…", laquo: "«", raquo: "»", laquo_space: "« ", raquo_space: " »",
27
+ lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”"
28
+ }.freeze
29
+ STATUS_FILL = {
30
+ "green" => "#d9f2e0", "yellow" => "#fff3cc", "red" => "#fde8e8", "blue" => "#ddeeff", "grey" => "#e8e8e8",
31
+ "gray" => "#e8e8e8", "purple" => "#eadcf8"
32
+ }.freeze
33
+ EMBED = /\A!\[\[([^\]|]+)\]\]\z/
34
+ # `![[Embed]]`, `[[Target|label]]` and the `{macro:arguments}` of the wiki (see Helpers::Macros)
35
+ INLINE = /(!)?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]|\{([a-z][a-z0-9-]*)(?::([^{}|\n]*))?\}/
36
+ ALIGNMENT = { left: "left", center: "center", right: "right" }.freeze
37
+
38
+ # @param heading_offset [Integer] added to the level of every heading
39
+ def initialize(heading_offset: 0, image: nil, diagram: nil, label: nil, embeds: nil)
40
+ @heading_offset = heading_offset
41
+ @image = image
42
+ @diagram = diagram
43
+ @label = label
44
+ @embeds = embeds
45
+ end
46
+
47
+ # @param inline [Boolean] for a table cell or list item: paragraphs are joined by a line break
48
+ # @return [String] Typst markup
49
+ def render(source, inline: false)
50
+ out = blocks(Archsight::Markdown.document(source).root.children)
51
+ inline ? out.gsub(/\n\n+/, " #linebreak() ") : out
52
+ end
53
+
54
+ private
55
+
56
+ def blocks(elements)
57
+ elements.filter_map { |node| block(node) }.join("\n\n")
58
+ end
59
+
60
+ def block(node)
61
+ case node.type
62
+ when :p then paragraph(node)
63
+ when :header then "#heading(level: #{[node.options[:level] + @heading_offset, 6].min})[#{inlines(node.children)}]"
64
+ when :codeblock then code(node.value, node.options[:lang])
65
+ when :blockquote then "#quote(block: true)[#{blocks(node.children)}]"
66
+ when :ul then "#list(#{items(node)})"
67
+ when :ol then "#enum(#{items(node)})"
68
+ when :hr then "#line(length: 100%, stroke: 0.5pt + luma(200))"
69
+ when :table then table(node)
70
+ when :blank, :html_element, :xml_comment then nil
71
+ else inlines(node.children) unless node.children.empty?
72
+ end
73
+ end
74
+
75
+ # A paragraph that is only an image is a figure, centred; one that is only embeds (one per line) is their content
76
+ def paragraph(node)
77
+ content = node.children.reject { |c| c.type == :text && c.value.strip.empty? }
78
+ return image(content.first) if content.size == 1 && content.first.type == :img
79
+
80
+ embeds = block_embeds(content)
81
+ return embeds if embeds
82
+
83
+ inlines(node.children)
84
+ end
85
+
86
+ def block_embeds(content)
87
+ return nil unless @embeds && content.size == 1 && content.first.type == :text
88
+
89
+ lines = content.first.value.lines.map(&:strip).reject(&:empty?)
90
+ return nil unless lines.any? && lines.all? { |line| line.match?(EMBED) }
91
+
92
+ lines.map { |line| @embeds.embed(line[EMBED, 1].strip) }.join("\n\n")
93
+ end
94
+
95
+ def items(list)
96
+ list.children.map { |li| "[#{blocks(li.children)}]" }.join(", ")
97
+ end
98
+
99
+ def code(source, lang)
100
+ case lang.to_s
101
+ when "asd" then @diagram ? @diagram.call(source) : raw(source, lang)
102
+ when "view" then @embeds ? @embeds.view_block(source) : raw(source, lang)
103
+ when "requirements" then @embeds ? @embeds.requirements_block(source) : raw(source, lang)
104
+ when "mermaid" then [note("Mermaid diagram, shown as a diagram in Archsight", fill: "#f2f2f2"), raw(source, lang)].join("\n\n")
105
+ else raw(source, lang)
106
+ end
107
+ end
108
+
109
+ def table(node)
110
+ head, body = node.children.partition { |section| section.type == :thead }
111
+ head_rows = head.flat_map(&:children).map { |tr| cells(tr, header: true) }
112
+ body_rows = body.flat_map(&:children).map { |tr| cells(tr) }
113
+ width = (head_rows + body_rows).map(&:size).max || 1
114
+ header = head_rows.empty? ? "" : "table.header(#{head_rows.flatten.join(", ")}), "
115
+ "#table(columns: #{width}, #{align(node, width)}#{header}#{body_rows.flatten.join(", ")})"
116
+ end
117
+
118
+ def align(node, width)
119
+ alignment = Array(node.options[:alignment])
120
+ return "" unless alignment.any? { |a| ALIGNMENT.key?(a) }
121
+
122
+ "align: (#{Array.new(width) { |i| ALIGNMENT.fetch(alignment[i], "auto") }.join(", ")},), "
123
+ end
124
+
125
+ # The header cells are semibold, set on the cells (a show rule on the table header does not reach their text)
126
+ def cells(row, header: false)
127
+ row.children.map do |td|
128
+ content = inlines(td.children)
129
+ header ? "[#text(weight: \"semibold\")[#{content}]]" : "[#{content}]"
130
+ end
131
+ end
132
+
133
+ def inlines(elements)
134
+ elements.map { |node| inline(node) }.join
135
+ end
136
+
137
+ def inline(node)
138
+ case node.type
139
+ when :text then text(node.value)
140
+ when :em then "#emph[#{inlines(node.children)}]"
141
+ when :strong then "#strong[#{inlines(node.children)}]"
142
+ when :codespan then "#raw(#{lit(node.value)})"
143
+ when :a then "#link(#{lit(node.attr["href"])})[#{inlines(node.children)}]"
144
+ when :br then "#linebreak()"
145
+ when :img then image(node)
146
+ when :entity then str(node.value.char)
147
+ when :smart_quote, :typographic_sym then str(SYMBOLS.fetch(node.value, node.value.to_s))
148
+ when :html_element then html(node)
149
+ else inlines(node.children)
150
+ end
151
+ end
152
+
153
+ def html(node)
154
+ return "#linebreak()" if node.value == "br"
155
+
156
+ checkbox(node) || inlines(node.children)
157
+ end
158
+
159
+ def checkbox(node)
160
+ return unless node.value == "input" && node.attr["type"] == "checkbox"
161
+
162
+ str(node.attr.key?("checked") ? "☑ " : "☐ ")
163
+ end
164
+
165
+ def image(node)
166
+ src = node.attr["src"].to_s
167
+ alt = node.attr["alt"].to_s
168
+ @image&.call(src, alt) || "#emph[#{str(alt.empty? ? src : alt)}]"
169
+ end
170
+
171
+ # Plain text, in which wiki links, embeds and the macros of the wiki are replaced
172
+ def text(value)
173
+ out = +""
174
+ pos = 0
175
+ value.scan(INLINE) do
176
+ match = Regexp.last_match
177
+ out << str(value[pos...match.begin(0)]) if match.begin(0) > pos
178
+ out << (replacement(match) || str(match[0]))
179
+ pos = match.end(0)
180
+ end
181
+ out << str(value[pos..]) if pos < value.length
182
+ out
183
+ end
184
+
185
+ def replacement(match)
186
+ match[2] ? link(match) : macro(match[4], match[5])
187
+ end
188
+
189
+ def link(match)
190
+ label = match[3]&.strip || @label&.call(match[2].strip) || match[2].strip
191
+ match[1] ? "#emph[#{str(label)}]" : str(label)
192
+ end
193
+
194
+ # A registered macro with valid arguments; nil leaves the text as written. What needs the running application
195
+ # (`children`, `pagetree`) has no meaning on paper.
196
+ def macro(name, arguments)
197
+ handler = Helpers::Macros.handler(name)
198
+ return nil unless handler
199
+
200
+ value = handler.parse(arguments)
201
+ return nil unless value
202
+
203
+ case name
204
+ when "status" then badge(value.text.upcase, STATUS_FILL.fetch(value.colour.to_s.downcase, "#e8e8e8"))
205
+ when "jira" then jira(value)
206
+ when "emoticon" then str(value.char)
207
+ when "children", "pagetree" then ""
208
+ end
209
+ end
210
+
211
+ def jira(key)
212
+ url = JiraSettings.link_for(key)
213
+ url ? "#link(#{lit(url)})[#{str(key)}]" : "#raw(#{lit(key)})"
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "resolv"
5
+ require "ipaddr"
6
+ require "uri"
7
+ require_relative "../user_config"
8
+
9
+ module Archsight
10
+ module Pdf
11
+ # Images that a page embeds by URL (`![](https://...)`), fetched by the server so they can go into the PDF.
12
+ #
13
+ # The URL comes from page content, so the server must not become a way into the network behind it: only http(s) on
14
+ # the default ports, only hosts that resolve to public addresses (checked for the address that is connected to, and
15
+ # again for every redirect), a size and time limit, and the answer must really be a png, jpeg, gif or webp image.
16
+ # Anything else is `nil` and the document shows the alt text. `pdf.remote_images: false`
17
+ # (`ARCHSIGHT_PDF_REMOTE_IMAGES`) turns fetching off.
18
+ module RemoteImage
19
+ MAX_BYTES = 10 * 1024 * 1024
20
+ TIMEOUT = 10
21
+ MAX_REDIRECTS = 3
22
+ CACHE_SECONDS = 3600
23
+ CACHE_ENTRIES = 64
24
+ PORTS = { "http" => 80, "https" => 443 }.freeze
25
+ NON_PUBLIC = %w[
26
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16
27
+ 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8
28
+ ].map { |range| IPAddr.new(range) }.freeze
29
+ TYPES = { png: ".png", jpeg: ".jpg", gif: ".gif", webp: ".webp" }.freeze
30
+
31
+ @cache = {}
32
+ @lock = Mutex.new
33
+
34
+ class << self
35
+ # @return [Boolean] whether fetching is on
36
+ def enabled?(env: ENV)
37
+ !%w[false no 0 off].include?(UserConfig.setting("pdf", "remote_images", env: env).to_s.strip.downcase)
38
+ end
39
+
40
+ # @return [Array(String, String), nil] image bytes and the file extension, nil if it cannot be had
41
+ def fetch(url, resolver: Resolv, now: Time.now)
42
+ cached(url, now) { download(url, resolver) }
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ # Public addresses only: not loopback, private, link-local (cloud metadata), multicast, reserved or unspecified
48
+ def public_address?(address)
49
+ ip = IPAddr.new(address.to_s)
50
+ ip = ip.native if ip.ipv4_mapped?
51
+ NON_PUBLIC.none? { |range| range.include?(ip) }
52
+ rescue IPAddr::Error
53
+ false
54
+ end
55
+
56
+ # @return [Symbol, nil] :png, :jpeg, :gif or :webp from the first bytes
57
+ def image_type(bytes)
58
+ head = bytes.byteslice(0, 12).to_s.b
59
+ return :png if head.start_with?("\x89PNG\r\n\x1a\n".b)
60
+ return :jpeg if head.start_with?("\xFF\xD8\xFF".b)
61
+ return :gif if head.start_with?("GIF87a", "GIF89a")
62
+
63
+ :webp if head.start_with?("RIFF") && head.byteslice(8, 4) == "WEBP"
64
+ end
65
+
66
+ private
67
+
68
+ def cached(url, now)
69
+ entry = @lock.synchronize { @cache[url] }
70
+ return entry[:value] if entry && now - entry[:at] < CACHE_SECONDS
71
+
72
+ value = yield
73
+ @lock.synchronize do
74
+ @cache[url] = { value: value, at: now }
75
+ @cache.shift while @cache.size > CACHE_ENTRIES
76
+ end
77
+ value
78
+ end
79
+
80
+ def download(url, resolver, redirects = 0)
81
+ uri = URI.parse(url)
82
+ return nil unless PORTS.key?(uri.scheme) && uri.host && uri.port == PORTS[uri.scheme] && uri.userinfo.nil?
83
+
84
+ addresses = resolver.getaddresses(uri.host).map(&:to_s)
85
+ return nil if addresses.empty? || !addresses.all? { |a| public_address?(a) }
86
+
87
+ response, body = get(uri, addresses.first)
88
+ case response
89
+ when Net::HTTPSuccess then image(body)
90
+ when Net::HTTPRedirection
91
+ redirects < MAX_REDIRECTS ? download(URI.join(url, response["location"].to_s).to_s, resolver, redirects + 1) : nil
92
+ end
93
+ end
94
+
95
+ # Connects to the address that was checked (not to a second DNS answer); the certificate is checked for the name.
96
+ # @return [Array(Net::HTTPResponse, String), nil] the response and its body, nil if the body is too large
97
+ def get(uri, address)
98
+ http = Net::HTTP.new(uri.host, uri.port)
99
+ http.ipaddr = address
100
+ http.use_ssl = uri.scheme == "https"
101
+ http.open_timeout = http.read_timeout = http.write_timeout = TIMEOUT
102
+ http.request(Net::HTTP::Get.new(uri.request_uri, "Accept" => "image/*", "User-Agent" => "archsight-pdf")) do |res|
103
+ return [res, nil] unless res.is_a?(Net::HTTPSuccess)
104
+ return nil if res["Content-Length"].to_i > MAX_BYTES
105
+
106
+ body = +"".b
107
+ res.read_body do |chunk|
108
+ body << chunk
109
+ return nil if body.bytesize > MAX_BYTES
110
+ end
111
+ return [res, body]
112
+ end
113
+ end
114
+
115
+ def image(bytes)
116
+ type = image_type(bytes.to_s)
117
+ type ? [bytes, TYPES.fetch(type)] : nil
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typst"
4
+
5
+ module Archsight
6
+ module Pdf
7
+ # The facts of a resource that is not a page, as Typst markup: its properties (annotations) and its relations in
8
+ # both directions. Structure comes from alignment and light rules, not from boxes: a muted label column, rows
9
+ # divided by hairlines, related annotations kept together, and the resources of a relation flowing in columns
10
+ # instead of one long comma-separated line.
11
+ class ResourceSheet
12
+ SKIP = %w[architecture/description architecture/diagram architecture/tags].freeze
13
+ LONG = 400 # characters; longer single-line values still sit in the table, wrapped, up to MAX
14
+ MAX = 2000
15
+ RULE = "0.4pt + theme.muted.lighten(75%)" # as the rule under the rows of every other table
16
+ NAMES_IN_COLUMNS = 4 # from this many resources a relation lists them in two columns
17
+
18
+ # @param markdown [#call] `(String) -> Typst`, renders markdown the way the document does
19
+ def initialize(resource, markdown:)
20
+ @resource = resource
21
+ @markdown = markdown
22
+ end
23
+
24
+ # @return [Array<String>] the sections (heading and content) of the properties, none if there are none
25
+ def properties
26
+ short, long = annotations.partition { |key, value| table_value?(key, value) }
27
+ parts = []
28
+ parts << "#heading(level: 1)[#{Typst.str("Properties")}]\n\n#{property_table(short)}" unless short.empty?
29
+ long.each { |key, value| parts << long_value(key, value) }
30
+ parts
31
+ end
32
+
33
+ # @return [Array<String>] the sections of the outgoing and the incoming relations
34
+ def relations
35
+ out = outgoing
36
+ incoming = self.incoming
37
+ return [] if out.empty? && incoming.empty?
38
+
39
+ parts = ["#heading(level: 1)[#{Typst.str("Relations")}]"]
40
+ parts << "#heading(level: 2)[#{Typst.str("Outgoing")}]\n\n#{relation_table(out)}" unless out.empty?
41
+ parts << "#heading(level: 2)[#{Typst.str("Incoming")}]\n\n#{caption("Resources that point to this one; the relation is read from their side.")}\n\n#{relation_table(incoming)}" unless incoming.empty?
42
+ parts
43
+ end
44
+
45
+ # `dependsOn` -> "depends on"
46
+ def self.humanize(verb)
47
+ verb.to_s.gsub(/([a-z0-9])([A-Z])/, '\1 \2').downcase
48
+ end
49
+
50
+ private
51
+
52
+ def klass = @resource.class
53
+
54
+ def annotations
55
+ @resource.annotations.reject { |key, value| SKIP.include?(key) || value.to_s.strip.empty? }
56
+ end
57
+
58
+ # Multi-line and markdown values are text, not table cells
59
+ def table_value?(key, value)
60
+ !value.to_s.include?("\n") && !%w[markdown multiline].include?(klass.annotation_format(key).to_s)
61
+ end
62
+
63
+ def display(key, value)
64
+ text = value.to_s
65
+ text = text.split(",").map(&:strip).reject(&:empty?).join(", ") if klass.annotation_matching(key)&.list?
66
+ text.length > MAX ? "#{text[0, MAX]}…" : text
67
+ end
68
+
69
+ # Annotations of one namespace (`git/...`, `lifecycle/...`) stay together, separated from the next by a gap
70
+ def property_table(pairs)
71
+ grouped = pairs.group_by { |key, _| key.split("/").first }.values
72
+ cells = grouped.each_with_index.flat_map do |group, index|
73
+ gap = index.zero? ? [] : ["table.cell(colspan: 2, stroke: none, inset: 3pt)[]"]
74
+ gap + group.flat_map do |key, value|
75
+ ["[#text(fill: theme.muted)[#{Typst.str(klass.annotation_title(key))}]]", "[#{Typst.str(display(key, value))}]"]
76
+ end
77
+ end
78
+ "#table(columns: (30%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
79
+ end
80
+
81
+ def long_value(key, value)
82
+ body = klass.annotation_format(key).to_s == "markdown" ? @markdown.call(value.to_s) : multiline(value.to_s)
83
+ "#heading(level: 2)[#{Typst.str(klass.annotation_title(key))}]\n\n#{body}"
84
+ end
85
+
86
+ def multiline(text)
87
+ text.lines.map { |line| Typst.str(line.chomp) }.join("#linebreak()")
88
+ end
89
+
90
+ # { verb => { kind => [instances] } }
91
+ def outgoing = @resource.relations_grouped
92
+
93
+ # incoming comes as { kind => { verb => [instances] } }, shown like the outgoing ones
94
+ def incoming
95
+ @resource.references_grouped.each_with_object({}) do |(kind, verbs), by_verb|
96
+ verbs.each { |verb, instances| (by_verb[verb] ||= {})[kind] = instances }
97
+ end.sort.to_h
98
+ end
99
+
100
+ # The relation (said once for all of its kinds), the kind, and the names of the resources in one or two columns
101
+ def relation_table(by_verb)
102
+ cells = by_verb.flat_map do |verb, kinds|
103
+ kinds.each_with_index.flat_map do |(kind, instances), index|
104
+ row = []
105
+ row << "table.cell(rowspan: #{kinds.size})[#text(weight: \"semibold\")[#{Typst.str(self.class.humanize(verb))}]]" if index.zero?
106
+ row << "[#pad(right: 8pt, text(fill: theme.muted)[#{Typst.str(breakable(kind))}])]"
107
+ row << "[#{names(instances)}]"
108
+ end
109
+ end
110
+ "#table(columns: (17%, 24%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
111
+ end
112
+
113
+ def names(instances)
114
+ list = instances.map(&:name).uniq.sort.map { |name| "[#{Typst.str(breakable(name))}]" }
115
+ columns = list.size >= NAMES_IN_COLUMNS ? "1fr, 1fr" : "1fr"
116
+ "#grid(columns: (#{columns},), column-gutter: 10pt, row-gutter: 4pt, #{list.join(", ")})"
117
+ end
118
+
119
+ # Names have no spaces: allow a line break after each `:` and between camel-case words (no visible change)
120
+ def breakable(name)
121
+ name.gsub(":", ":\u200B").gsub(/([a-z0-9])([A-Z])/, "\\1\u200B\\2")
122
+ end
123
+
124
+ def caption(text)
125
+ "#text(size: 8.5pt, fill: theme.muted)[#{Typst.str(text)}]"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "document"
4
+
5
+ module Archsight
6
+ module Pdf
7
+ # A fixed sample document in the look of one pdf Config, to see what the settings do without exporting a real page:
8
+ # a cover page and a page with the elements a document is made of (headings, text, list, quote, table with
9
+ # status, code). The content is neutral on purpose; only the Config shapes the result. The "resource" of the document
10
+ # is the Config (an Archsight::Resources::Config of type pdf).
11
+ class SampleDocument < Document
12
+ PARTS = %w[cover page].freeze
13
+ TITLE = "Sample document"
14
+ BODY = <<~MD
15
+ ## Summary
16
+
17
+ This page shows how headings, text, lists and tables look with the settings of the Config. It is a fixed sample,
18
+ not one of your documents, so every change of a colour, a font or the logo is visible at once.
19
+
20
+ > A quote stands out from the text around it and is set in the same typeface.
21
+
22
+ ### What a document contains
23
+
24
+ - Text with **strong** and *emphasised* words, `inline code` and a [link](https://example.com)
25
+ - Lists, tables, diagrams and code, set in the fonts of the Config
26
+ - Status badges such as {status:green Active} and {status:yellow Planned}
27
+
28
+ ## Components
29
+
30
+ | Component | Owner | Status |
31
+ |:----------|:------|:-------|
32
+ | Billing API | Payments | {status:green Active} |
33
+ | Export service | Data | {status:yellow Deprecated} |
34
+ | Legacy gateway | Platform | {status:red End of life} |
35
+
36
+ ### Interface
37
+
38
+ ```ruby
39
+ def total(items)
40
+ items.sum(&:price)
41
+ end
42
+ ```
43
+ MD
44
+
45
+ # @param part [String, nil] `cover` or `page` for just that page (a document of one page, so a viewer needs no
46
+ # navigation), else both
47
+ def initialize(database, config, part: nil, **)
48
+ @part = part if PARTS.include?(part)
49
+ super(database, config, **)
50
+ end
51
+
52
+ def filename = "#{["preview", super.delete_suffix(".pdf"), @part].compact.join("-")}.pdf"
53
+
54
+ private
55
+
56
+ def theme_for(_template) = Theme.new(@resource, resources_dir: @resources_dir)
57
+ def title_for = TITLE
58
+ def tag_list = @part == "page" ? [] : %w[pdf:cover]
59
+ def body(images) = @part == "cover" ? "" : markdown(images).render(BODY)
60
+ def doc_id = "SAMPLE-001"
61
+ def version = "1.0"
62
+ def owner = "Alex Example"
63
+ def kind = "Sample"
64
+ def classification = @theme.classification
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typst"
4
+ require_relative "markdown"
5
+ require_relative "../view_table"
6
+ require_relative "../export/confluence/tables"
7
+
8
+ module Archsight
9
+ module Pdf
10
+ # A ViewTable::Table (the result of a view, of requirements or of an analysis) as a Typst table. Views are live
11
+ # in Archsight; in the PDF they are the data of the moment of the export.
12
+ module Tables
13
+ # A column whose longest text is longer than this shares the width (`fr`), shorter ones take what they need
14
+ LONG = 30
15
+ SMALL_FONT_FROM = 4 # columns
16
+ Confluence = Export::Confluence::Tables
17
+
18
+ module_function
19
+
20
+ # @param table [Archsight::ViewTable::Table]
21
+ # @param markdown [#call] Typst markup of a markdown text, for the cells of `as: :markdown`
22
+ # @param empty [String] shown instead of the table when there are no rows
23
+ # @return [String] Typst markup
24
+ def typst(table, markdown:, empty: "No results")
25
+ parts = []
26
+ parts << heading(table) unless table.title.to_s.empty?
27
+ parts << (table.rows.empty? ? Typst.note(empty, fill: "#f2f2f2") : grid(table, markdown))
28
+ parts << "#emph[#{Typst.str("#{table.cut} more #{table.cut == 1 ? "row" : "rows"} not shown, see Archsight")}]" if table.cut.positive?
29
+ parts.join("\n\n")
30
+ end
31
+
32
+ def heading(table)
33
+ count = "(#{table.total} #{table.total == 1 ? "item" : "items"})"
34
+ "#block(sticky: true)[#strong[#{Typst.str(table.title)}] #text(fill: luma(120))[#{Typst.str(count)}]]"
35
+ end
36
+
37
+ def grid(table, markdown)
38
+ head = table.columns.map { |c| "[#text(weight: \"semibold\")[#{Typst.str(c)}]]" }
39
+ cells = table.rows.flat_map { |row| row.map { |cell| "[#{cell(cell, markdown)}]" } }
40
+ out = "#table(columns: (#{widths(table).join(", ")},), table.header(#{head.join(", ")}), #{cells.join(", ")})"
41
+ table.columns.size >= SMALL_FONT_FROM ? "#[#set text(size: 8.5pt)\n#{out}]" : out
42
+ end
43
+
44
+ # `auto` for short columns, a share of the rest for long ones, so wide tables wrap instead of running off the page
45
+ def widths(table)
46
+ table.columns.each_index.map do |i|
47
+ longest = [table.columns[i].length, *table.rows.map { |row| row[i] ? text_of(row[i]).length : 0 }].max
48
+ longest > LONG ? "#{(longest / LONG.to_f).round.clamp(1, 4)}fr" : "auto"
49
+ end
50
+ end
51
+
52
+ def text_of(cell)
53
+ cell.resources.empty? ? cell.text.to_s : cell.resources.map(&:name).join(" ")
54
+ end
55
+
56
+ def cell(cell, markdown)
57
+ return cell.resources.map { |resource| Typst.str(resource.name) }.join("#linebreak()") unless cell.resources.empty?
58
+
59
+ case cell.as
60
+ when :status then badge(cell.text, Confluence::STATUS_COLOURS)
61
+ when :priority then cell.text.to_s.empty? ? "" : badge(cell.text, Confluence::PRIORITY_COLOURS)
62
+ when :markdown then markdown.call(cell.text.to_s)
63
+ else cell.text.to_s.split("\n").map { |line| Typst.str(line) }.join("#linebreak()")
64
+ end
65
+ end
66
+
67
+ def badge(text, colours)
68
+ Typst.badge(text, Markdown::STATUS_FILL.fetch(colours.fetch(text, "grey"), "#e8e8e8"))
69
+ end
70
+ end
71
+ end
72
+ end