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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+ require_relative "../user_config"
6
+
7
+ module Archsight
8
+ module Pdf
9
+ # Raised when a document cannot be turned into a PDF; the message is safe to show
10
+ class Error < StandardError; end
11
+
12
+ # Raised when typst is not installed
13
+ class Unavailable < Error; end
14
+
15
+ # Runs the `typst` command line tool on a prepared directory. The binary is `pdf.typst` of the user
16
+ # configuration (`ARCHSIGHT_PDF_TYPST`), else `typst` from the PATH.
17
+ class Compiler
18
+ DEFAULT_TIMEOUT = 60
19
+ ERROR_LINES = 12
20
+
21
+ def initialize(binary: nil, timeout: nil)
22
+ @binary = binary || UserConfig.setting("pdf", "typst") || "typst"
23
+ @timeout = (timeout || UserConfig.setting("pdf", "timeout") || DEFAULT_TIMEOUT).to_i
24
+ end
25
+
26
+ # @param dir [String] directory with main.typ; it is the Typst root, nothing outside of it can be read
27
+ # @param font_paths [Array<String>] directories with additional fonts
28
+ # @return [String] the PDF
29
+ # @raise [Unavailable] without typst
30
+ # @raise [Error] if typst fails or runs too long
31
+ def compile(dir, font_paths: [])
32
+ output = File.join(dir, "out.pdf")
33
+ args = [@binary, "compile", "--root", dir, *font_paths.flat_map { |p| ["--font-path", p] }, File.join(dir, "main.typ"), output]
34
+ _out, err, status = run(args, dir)
35
+ raise Error, "typst failed:\n#{err.lines.first(ERROR_LINES).join}" unless status.success? && File.file?(output)
36
+
37
+ File.binread(output)
38
+ end
39
+
40
+ # Whether PDF files can be placed as images (typst 0.14 and newer)
41
+ def pdf_images?
42
+ return @pdf_images if defined?(@pdf_images)
43
+
44
+ out, status = Open3.capture2(@binary, "--version")
45
+ version = out[/(\d+)\.(\d+)/]
46
+ @pdf_images = status.success? && !version.nil? && (Gem::Version.new(version) >= Gem::Version.new("0.14"))
47
+ rescue Errno::ENOENT
48
+ raise Unavailable, "typst is not installed (set ARCHSIGHT_PDF_TYPST to its path)"
49
+ end
50
+
51
+ private
52
+
53
+ def run(args, dir)
54
+ Open3.popen3(*args, chdir: dir) do |stdin, stdout, stderr, thread|
55
+ stdin.close
56
+ out = Thread.new { stdout.read }
57
+ err = Thread.new { stderr.read }
58
+ unless thread.join(@timeout)
59
+ Process.kill("KILL", thread.pid)
60
+ raise Error, "typst took longer than #{@timeout} seconds"
61
+ end
62
+ [out.value, err.value, thread.value]
63
+ end
64
+ rescue Errno::ENOENT
65
+ raise Unavailable, "typst is not installed (set ARCHSIGHT_PDF_TYPST to its path)"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "digest"
5
+ require_relative "../renderer"
6
+ require_relative "../graph"
7
+ require_relative "../user_config"
8
+ require_relative "../resources"
9
+
10
+ module Archsight
11
+ module Pdf
12
+ # The dependency graph of a resource as an SVG document, the picture the resource's page shows: the resources its
13
+ # relations lead to, as far as the page opens it (two steps, the layer of the resource and the one below it). The
14
+ # page draws it in the browser; here it is drawn by the `dot` command of Graphviz (`pdf.dot` /
15
+ # `ARCHSIGHT_PDF_DOT`, else `dot` from the PATH). A resource without relations has no graph, and without `dot`
16
+ # there is none either, which is not an error: the document is made without it.
17
+ class DependencyGraph
18
+ include Archsight::GraphvisRenderer
19
+
20
+ DEPTH = 2
21
+ BOX = [500.0, 480.0].freeze # points: the text width of a page and the height a graph may take (to choose the direction)
22
+ DIRECTIONS = %w[LR TB].freeze # left to right as on the page, or top to bottom
23
+ TIMEOUT = 30
24
+ CACHE_ENTRIES = 32
25
+
26
+ # The colours of the layers (the page colours its nodes with the same, by CSS)
27
+ LAYER_COLOURS = {
28
+ "strategy" => "#F4A261", "business" => "#F9DC5C", "application" => "#6CBEED", "technology" => "#8AC926",
29
+ "implementation" => "#F48FB1", "motivation" => "#CE93D8", "other" => "#cccccc"
30
+ }.freeze
31
+ # For the SVG, where the colours come as CSS: the page's style, without the borders of the label tables
32
+ STYLE = "<style>\n#{LAYER_COLOURS.map { |layer, hex| ".layer-#{layer} path[fill=\"lightgrey\"] { fill: #{hex}; stroke: #{hex}; }" }.join("\n")}\n" \
33
+ ".node polygon[fill=\"none\"] { stroke: none; }\n.node text { fill: #000000; }\n</style>\n".freeze
34
+ LAYER_CLASS = /("class" = "[^"]*\blayer-(\w+)[^"]*")/
35
+
36
+ @cache = {}
37
+ @lock = Mutex.new
38
+
39
+ class << self
40
+ # Colours the nodes by layer, as the page does (the DOT has no colours, they come from the page's CSS)
41
+ # @return [String] the SVG with a style element after its opening tag
42
+ def colour_layers(svg)
43
+ svg.sub(/(<svg\b[^>]*>)/) { "#{Regexp.last_match(1)}\n#{STYLE}" }
44
+ end
45
+
46
+ # The DOT of the page with what the page's CSS adds: the fill of a node by its layer, and label tables without borders
47
+ def paint(dot)
48
+ dot.gsub(LAYER_CLASS) do
49
+ hex = LAYER_COLOURS.fetch(Regexp.last_match(2), LAYER_COLOURS["other"])
50
+ "#{Regexp.last_match(1)} \"fillcolor\" = \"#{hex}\" \"color\" = \"#{hex}\""
51
+ end.gsub("<TABLE>", '<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0">')
52
+ end
53
+
54
+ def cached(key)
55
+ @lock.synchronize { @cache[key] } || yield.tap do |value|
56
+ @lock.synchronize do
57
+ @cache[key] = value
58
+ @cache.shift while @cache.size > CACHE_ENTRIES
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # @param layers [Array<String>, nil] the layers to follow; the page's default for the resource's layer if nil
65
+ def initialize(database, resource, depth: DEPTH, layers: nil, binary: nil)
66
+ @database = database
67
+ @resource = resource
68
+ @depth = depth
69
+ @layers = layers || Archsight::GraphvisRenderer.default_graph_layers(resource.class.layer)
70
+ @binary = binary || UserConfig.setting("pdf", "dot") || "dot"
71
+ end
72
+
73
+ # @return [Boolean] whether the graph would show more than the resource itself
74
+ def relations?
75
+ !graph_targets(@resource, @layers).empty?
76
+ end
77
+
78
+ # @return [String, nil] the graph as a PDF page (drawn by `dot` itself, with the fonts it measured the text with), nil
79
+ # if there is nothing to draw or `dot` is not installed or fails
80
+ def pdf = draw("pdf")
81
+
82
+ # @return [String, nil] the graph as SVG, coloured by CSS; nil as for #pdf
83
+ def svg
84
+ out = draw("svg")
85
+ out && self.class.colour_layers(out.dup.force_encoding(Encoding::UTF_8))
86
+ end
87
+
88
+ private
89
+
90
+ def draw(format)
91
+ return nil unless relations?
92
+
93
+ dot = self.class.paint(create_graph_one(@database, @resource.klass, @resource.name, max_depth: @depth, layers: @layers))
94
+ self.class.cached(Digest::SHA256.hexdigest("#{@binary}\0#{format}\0#{dot}")) { run(format, oriented(dot)) }
95
+ end
96
+
97
+ # The direction in which the graph is largest on the page: wide graphs read best left to right, tall ones top to
98
+ # bottom. Left to right (the page's) wins unless the other is clearly better.
99
+ def oriented(dot)
100
+ DIRECTIONS.each_with_index.map { |direction, index| [with_direction(dot, direction), index.zero? ? 1.05 : 1.0] }
101
+ .max_by { |candidate, bonus| fit(candidate) * bonus }.first
102
+ end
103
+
104
+ def with_direction(dot, direction)
105
+ dot.sub(/"rankdir" = "?\w+"?/, "\"rankdir\" = \"#{direction}\"")
106
+ end
107
+
108
+ # How much of its natural size the graph keeps when it is made to fit the box (1.0: nothing lost); 0 if `dot` fails
109
+ def fit(dot)
110
+ out, status = Open3.capture2(@binary, "-Tplain", stdin_data: dot)
111
+ return 0 unless status.success?
112
+
113
+ _graph, _scale, width, height = out.lines.first.split
114
+ [BOX[0] / (width.to_f * 72), BOX[1] / (height.to_f * 72), 1.0].min
115
+ rescue Errno::ENOENT
116
+ 0
117
+ end
118
+
119
+ def run(format, dot)
120
+ out, status = Open3.capture2(@binary, "-T#{format}", stdin_data: dot, binmode: true)
121
+ status.success? && !out.empty? ? out : nil
122
+ rescue Errno::ENOENT
123
+ nil
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "digest"
5
+ require_relative "typst"
6
+ require_relative "markdown"
7
+ require_relative "theme"
8
+ require_relative "images"
9
+ require_relative "embeds"
10
+ require_relative "resource_sheet"
11
+ require_relative "dependency_graph"
12
+ require_relative "../helpers/resource_resolver"
13
+ require_relative "../helpers/diagram_includes"
14
+ require_relative "../helpers/wiki_links"
15
+
16
+ module Archsight
17
+ module Pdf
18
+ # One resource or page as the files typst needs: `main.typ` (the document), the layout library, the theme
19
+ # and the images. Pages are exported as written; any other resource becomes a generated document (description,
20
+ # properties, relations, diagram).
21
+ #
22
+ # Tags steer the export (page `page/tags`, resource `architecture/tags`):
23
+ #
24
+ # pdf:cover cover page instead of a title block
25
+ # pdf:toc table of contents
26
+ # pdf:landscape landscape pages
27
+ # pdf:draft "draft" watermark
28
+ # pdf:no-graph leave the dependency graph out of a resource document
29
+ # pdf:template:<name> pdf Config to use instead of `default`
30
+ # version:<x> document version (else the page property Version, else 1)
31
+ class Document
32
+ GRAPH_HEIGHT = 0.8 # share of the page height the dependency graph may take
33
+ LIBRARY = File.join(__dir__, "typst", "archsight.typ")
34
+
35
+ # @return [String] title of the document
36
+ attr_reader :title
37
+
38
+ # @param template [String, nil] name of a pdf Config, overrides the tag
39
+ # @param draft [Boolean] force the watermark
40
+ def initialize(database, resource, template: nil, draft: false, resources_dir: Archsight.resources_dir, pdf_images: true)
41
+ @database = database
42
+ @resource = resource
43
+ @resources_dir = resources_dir
44
+ @pdf_images = pdf_images
45
+ @tags = tag_list
46
+ @theme = theme_for(template)
47
+ @draft = draft || @tags.include?("pdf:draft")
48
+ @title = title_for
49
+ end
50
+
51
+ # @return [Hash{String => String}] file name => content of everything typst needs, `main.typ` among them
52
+ def files
53
+ @files ||= build
54
+ end
55
+
56
+ # @return [Array<String>] directories with fonts to hand to typst
57
+ def font_paths
58
+ [@theme.font_dir].compact
59
+ end
60
+
61
+ # A name for the downloaded file
62
+ def filename
63
+ "#{@resource.name.to_s.gsub(/[^A-Za-z0-9._-]+/, "-").gsub(/\A[-.]+|-+\z/, "")}.pdf"
64
+ end
65
+
66
+ # Changes whenever the PDF would: the files and the fonts
67
+ def fingerprint
68
+ digest = Digest::SHA256.new
69
+ files.sort.each { |name, content| digest << name << "\0" << content.b << "\0" }
70
+ font_paths.each { |dir| Dir.children(dir).sort.each { |f| digest << f << File.size(File.join(dir, f)).to_s } }
71
+ digest.hexdigest[0, 16]
72
+ end
73
+
74
+ private
75
+
76
+ # The look, the title and the body are what a variant (see SampleDocument) replaces
77
+ def theme_for(template)
78
+ Theme.for(@database, template || tag_value("pdf:template"))
79
+ end
80
+
81
+ def title_for
82
+ page? ? @resource.title : @resource.name
83
+ end
84
+
85
+ def body(images)
86
+ page? ? page_body(images) : resource_body(images)
87
+ end
88
+
89
+ def page?
90
+ @resource.klass == "Page"
91
+ end
92
+
93
+ def build
94
+ images = Images.new(base_dir: base_dir, resources_dir: @resources_dir, resolver: resolver,
95
+ includes: Helpers::DiagramIncludes.for(@database), pdf_images: @pdf_images)
96
+ body = body(images)
97
+ files = { "archsight.typ" => File.read(LIBRARY), "main.typ" => main(body) }.merge(images.files)
98
+ files.merge(logo_files).tap { |f| f["theme.typ"] = @theme.to_typst(**logo_names) }
99
+ end
100
+
101
+ def main(body)
102
+ meta = "title: #{Typst.lit(@title)}, doc-id: #{opt(doc_id)}, version: #{opt(version)}, date: #{opt(date)}, owner: #{opt(owner)}"
103
+ args = "theme: theme, #{meta}"
104
+ front = if @tags.include?("pdf:cover")
105
+ "#cover-page(#{args}, kind: #{opt(kind)}, classification: #{opt(classification)})"
106
+ else
107
+ "#title-block(theme: theme, title: #{Typst.lit(@title)}, kind: #{opt(kind)})"
108
+ end
109
+ front += "\n#toc-page(theme: theme)" if @tags.include?("pdf:toc")
110
+ <<~TYPST
111
+ #import "archsight.typ": *
112
+ #import "theme.typ": theme
113
+ #show: doc.with(#{args}, classification: #{opt(classification)}, draft: #{@draft}, landscape: #{@tags.include?("pdf:landscape")})
114
+ #{front}
115
+
116
+ #{body}
117
+ TYPST
118
+ end
119
+
120
+ # ---- body
121
+
122
+ def page_body(images)
123
+ markdown(images).render(strip_title(@resource.annotations["page/content"].to_s))
124
+ end
125
+
126
+ def resource_body(images)
127
+ ann = @resource.annotations
128
+ sheet = ResourceSheet.new(@resource, markdown: ->(text) { markdown(images, offset: 2).render(text) })
129
+ parts = []
130
+ # the pictures come first, right after the title, then the text and the facts
131
+ parts.concat(dependency_graph(images))
132
+ parts << images.diagram(ann["architecture/diagram"]).to_s if ann["architecture/diagram"].to_s.strip != ""
133
+ parts << markdown(images, offset: 1).render(ann["architecture/description"]) if ann["architecture/description"].to_s.strip != ""
134
+ parts.concat(sheet.properties)
135
+ parts.concat(sheet.relations)
136
+ parts.join("\n\n")
137
+ end
138
+
139
+ # The dependency graph the resource's page shows, unless the tag `pdf:no-graph` leaves it out
140
+ def dependency_graph(images)
141
+ return [] if @tags.include?("pdf:no-graph")
142
+
143
+ graph = DependencyGraph.new(@database, @resource)
144
+ picture = (images.pdf(graph.pdf, max_height: GRAPH_HEIGHT) if @pdf_images && graph.pdf) ||
145
+ (images.vector(graph.svg, max_height: GRAPH_HEIGHT) if graph.svg)
146
+ picture ? [picture] : []
147
+ end
148
+
149
+ # @param embeds [Boolean] whether views, analyses and `![[..]]` embeds are part of the text; the content of an embed
150
+ # is rendered without (one level, so embeds cannot include each other)
151
+ def markdown(images, offset: 0, embeds: true)
152
+ links = Helpers::WikiLinks.new(@database, resolver: resolver)
153
+ Markdown.new(heading_offset: offset, image: images.method(:image), diagram: images.method(:diagram), label: links.method(:label_for),
154
+ embeds: embeds ? embeds_for(images) : nil)
155
+ end
156
+
157
+ def embeds_for(images)
158
+ @embeds_for ||= Embeds.new(@database, markdown: ->(inline, text) { markdown(images, offset: 1, embeds: false).render(text, inline: inline) })
159
+ end
160
+
161
+ # The header already shows the title, so a leading "# Title" that repeats it is dropped
162
+ def strip_title(body)
163
+ first, rest = body.lstrip.split("\n", 2)
164
+ heading = first.to_s[/\A#\s+(.+?)\s*#*\s*\z/, 1]
165
+ heading && [@title, @resource.name].any? { |t| t.to_s.casecmp?(heading) } ? rest.to_s.lstrip : body
166
+ end
167
+
168
+ # ---- metadata
169
+
170
+ def properties_hash
171
+ @properties_hash ||= page? ? @resource.properties.to_h : {}
172
+ end
173
+
174
+ def tag_list
175
+ raw = @resource.annotations["page/tags"] || @resource.annotations["architecture/tags"]
176
+ raw.to_s.split(",").map(&:strip).reject(&:empty?)
177
+ end
178
+
179
+ # `prefix:value` of a tag
180
+ def tag_value(prefix)
181
+ @tags.filter_map { |t| t.delete_prefix("#{prefix}:") if t.start_with?("#{prefix}:") }.first
182
+ end
183
+
184
+ def doc_id = properties_hash["Document ID"] || "#{@resource.klass}/#{@resource.name}"
185
+ def version = tag_value("version") || properties_hash["Version"] || "1"
186
+ def classification = properties_hash["Classification"] || @theme.classification
187
+ def kind = page? ? nil : @resource.klass
188
+
189
+ def date
190
+ (@resource.annotations["page/updated"] || Date.today.iso8601).to_s[0, 10]
191
+ end
192
+
193
+ def owner
194
+ value = @resource.annotations["page/owner"] || @resource.annotations["page/author"] ||
195
+ @resource.annotations["architecture/owner"]
196
+ Annotations::EmailRecipient.parse(value)&.fetch(:name)
197
+ end
198
+
199
+ def base_dir
200
+ Assets.base_dir_for(@resource, resources_dir: @resources_dir)
201
+ end
202
+
203
+ def resolver
204
+ @resolver ||= Helpers::ResourceResolver.new(@database)
205
+ end
206
+
207
+ def opt(value)
208
+ value.to_s.strip.empty? ? "none" : Typst.lit(value)
209
+ end
210
+
211
+ # Logos are staged under fixed names; the file type decides the extension
212
+ def logo_names
213
+ { logo: staged_name("logo", @theme.logo), logo_light: staged_name("logo-light", @theme.logo_light) }
214
+ end
215
+
216
+ def logo_files
217
+ { logo_names[:logo] => @theme.logo, logo_names[:logo_light] => @theme.logo_light }
218
+ .reject { |name, file| name.nil? || file.nil? }
219
+ .transform_values { |file| File.binread(file) }
220
+ end
221
+
222
+ def staged_name(base, file)
223
+ file && "#{base}#{File.extname(file).downcase}"
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "typst"
5
+ require_relative "markdown"
6
+ require_relative "tables"
7
+ require_relative "../view_table"
8
+ require_relative "../requirements"
9
+ require_relative "../user_config"
10
+ require_relative "../helpers/embeds"
11
+ require_relative "../helpers/view_blocks"
12
+ require_relative "../helpers/requirements_blocks"
13
+ require_relative "../helpers/resource_resolver"
14
+ require_relative "../query"
15
+
16
+ module Archsight
17
+ module Pdf
18
+ # The live content of a page, as the data of the moment of the export (as the Confluence export does it): a View
19
+ # (`![[View/Name]]` or a ```view block) and the requirements of some resources as tables, an Analysis as its result,
20
+ # any other resource as its description. Nothing here raises: what cannot be shown is a note in the document, so
21
+ # that nothing disappears silently.
22
+ class Embeds
23
+ MAX_ANALYSES = 5 # per document, an analysis may run for as long as its own timeout
24
+ ANALYSIS_TTL = 60 # seconds a result is reused (the PDF endpoint builds the document for every request)
25
+ ANALYSIS_ENTRIES = 32
26
+ ANALYSIS_HEADING_OFFSET = 2
27
+
28
+ @results = {}
29
+ @lock = Mutex.new
30
+
31
+ class << self
32
+ # The result of an analysis, from a run of the last minute if there is one
33
+ def result(database, analysis, now: Time.now)
34
+ key = [analysis.name, Digest::SHA256.hexdigest(analysis.annotations.to_a.sort.inspect)]
35
+ entry = @lock.synchronize { @results[key] }
36
+ return entry[:result] if entry && now - entry[:at] < ANALYSIS_TTL
37
+
38
+ require_relative "../analysis"
39
+ result = Archsight::Analysis::Executor.new(database).execute(analysis)
40
+ @lock.synchronize do
41
+ @results[key] = { result: result, at: now }
42
+ @results.shift while @results.size > ANALYSIS_ENTRIES
43
+ end
44
+ result
45
+ end
46
+ end
47
+
48
+ # @param markdown [#call] `(Boolean inline, String) -> Typst`: renders markdown the way the document does
49
+ def initialize(database, markdown:, max_analyses: nil)
50
+ @database = database
51
+ @markdown = markdown
52
+ @max_analyses = (max_analyses || UserConfig.setting("pdf", "max_analyses") || MAX_ANALYSES).to_i
53
+ @analyses = 0
54
+ @resolver = Helpers::ResourceResolver.new(database)
55
+ end
56
+
57
+ def view_block(source)
58
+ table(ViewTable.from_block(@database, source))
59
+ rescue Helpers::ViewBlocks::Error, Archsight::Query::QueryError => e
60
+ problem("view block: #{e.message}")
61
+ end
62
+
63
+ def requirements_block(source)
64
+ table(Requirements.table(@database, Helpers::RequirementsBlocks.parse(source)), empty: "No requirements")
65
+ rescue Helpers::RequirementsBlocks::Error, Archsight::Query::QueryError => e
66
+ problem("requirements block: #{e.message}")
67
+ end
68
+
69
+ # @param reference [String] `Kind/Name` of `![[Kind/Name]]`
70
+ def embed(reference)
71
+ found = @resolver.find(reference)
72
+ return problem("#{reference}: not found") if found == :missing
73
+ return problem("#{reference}: ambiguous, write Kind/Name") if found == :ambiguous
74
+
75
+ kind, name = found
76
+ instance = @database.instance_by_kind(kind, name)
77
+ case kind
78
+ when "View" then view(instance)
79
+ when "Analysis" then analysis(instance)
80
+ else description(kind, instance, reference)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def table(table, empty: "No results")
87
+ Tables.typst(table, markdown: ->(text) { @markdown.call(true, text) }, empty: empty)
88
+ end
89
+
90
+ def problem(message)
91
+ Typst.note(message, fill: "#fde8e8")
92
+ end
93
+
94
+ def view(instance)
95
+ table(ViewTable.from_view(@database, instance))
96
+ rescue Helpers::ViewBlocks::Error, Archsight::Query::QueryError => e
97
+ problem("view #{instance.name}: #{e.message}")
98
+ end
99
+
100
+ # A page or menu is not content, a resource without description has nothing to show: its name stands there
101
+ def description(kind, instance, reference)
102
+ text = instance.annotations["architecture/description"].to_s.strip
103
+ return Typst.str(reference) unless Helpers::Embeds.embeddable_kind?(kind) && !text.empty?
104
+
105
+ @markdown.call(false, text)
106
+ end
107
+
108
+ def analysis(instance)
109
+ return problem("Analysis #{instance.name} not run: at most #{@max_analyses} analyses per document") if @analyses >= @max_analyses
110
+
111
+ @analyses += 1
112
+ result = self.class.result(@database, instance)
113
+ renderer = Archsight::Analysis::TypstRenderer.new([result], full: true)
114
+ parts = ["#strong[#{Typst.str(instance.name)}]"]
115
+ parts << renderer.error(result) if result.failed?
116
+ parts.concat(result.sections.filter_map { |section| analysis_section(renderer, section) })
117
+ parts.join("\n\n")
118
+ end
119
+
120
+ def analysis_section(renderer, section)
121
+ case section[:type]
122
+ when :table then table(analysis_table(section))
123
+ when :text then @markdown.call(false, section[:content].to_s)
124
+ else renderer.section(section, heading_offset: ANALYSIS_HEADING_OFFSET)
125
+ end
126
+ end
127
+
128
+ def analysis_table(section)
129
+ rows = section[:rows].map { |row| section[:headers].each_index.map { |i| ViewTable::Cell.new(text: row[i].to_s, as: :markdown) } }
130
+ ViewTable::Table.new(title: "", columns: section[:headers], rows: rows, total: rows.size)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "typst"
5
+ require_relative "remote_image"
6
+ require_relative "../assets"
7
+ require_relative "../diagram"
8
+ require_relative "../helpers/diagram_blocks"
9
+
10
+ module Archsight
11
+ module Pdf
12
+ # The pictures of a document: images next to the markdown and `asd` diagrams, copied (or converted) into the
13
+ # directory typst compiles in. Only what Archsight::Assets serves is ever read.
14
+ class Images
15
+ RASTER = %w[.png .jpg .jpeg .gif].freeze
16
+
17
+ # @return [Hash{String => String}] staged file name => content
18
+ attr_reader :files
19
+
20
+ # @param base_dir [String, nil] directory of the markdown file, relative to the resources directory
21
+ # @param pdf_images [Boolean] whether typst can place PDF files; else vector graphics stay SVG
22
+ def initialize(base_dir:, resources_dir:, resolver: nil, includes: nil, pdf_images: true, remote: RemoteImage.method(:fetch))
23
+ @base_dir = base_dir.to_s
24
+ @resources_dir = resources_dir
25
+ @resolver = resolver
26
+ @includes = includes
27
+ @pdf_images = pdf_images
28
+ @remote = remote && RemoteImage.enabled? ? remote : nil
29
+ @files = {}
30
+ end
31
+
32
+ # Hook for Markdown: Typst markup of the image a markdown reference names, nil if it cannot be shown
33
+ def image(src, _alt = nil)
34
+ return remote(src) if Assets.external?(src)
35
+
36
+ path = Assets.resolve_reference(src, @base_dir)
37
+ file = path && Assets.file_for(path, resources_dir: @resources_dir)
38
+ return nil unless file && File.size(file) <= Assets::MAX_BYTES
39
+
40
+ case File.extname(file).downcase
41
+ when *RASTER then place(File.binread(file), File.extname(file).downcase)
42
+ when ".svg" then vector(File.read(file, encoding: "UTF-8"))
43
+ when ".asd" then diagram(File.read(file, encoding: "UTF-8"))
44
+ when Assets::DRAWIO then Typst.note("draw.io diagram, shown in Archsight", fill: "#f2f2f2")
45
+ end
46
+ end
47
+
48
+ # Hook for Markdown: Typst markup of an `asd` diagram, or a note and the source if it does not render
49
+ def diagram(source)
50
+ vector(Helpers::DiagramBlocks.standalone_svg(source, resolver: @resolver, includes: @includes))
51
+ rescue Archsight::Diagram::Error => e
52
+ note = Typst.note("Diagram could not be rendered: #{e.message}", fill: "#fde8e8")
53
+ [note, Typst.raw(source, "asd")].join("\n\n")
54
+ end
55
+
56
+ # Vector graphics are placed as PDF (rsvg-convert paints text outlines correctly, typst's own SVG renderer
57
+ # does not honour `paint-order`), as SVG when that tool or PDF images are not available.
58
+ def vector(svg, max_height: nil)
59
+ pdf = @pdf_images ? Images.svg_to_pdf(svg) : nil
60
+ pdf ? place(pdf, ".pdf", max_height: max_height) : place(svg, ".svg", max_height: max_height)
61
+ end
62
+
63
+ # A PDF page that is already a picture (drawn by another tool), placed as it is
64
+ def pdf(bytes, max_height: nil)
65
+ place(bytes, ".pdf", max_height: max_height)
66
+ end
67
+
68
+ # @return [String, nil] PDF bytes of an SVG document, nil without rsvg-convert
69
+ def self.svg_to_pdf(svg)
70
+ out, status = Open3.capture2("rsvg-convert", "--format", "pdf", stdin_data: svg, binmode: true)
71
+ status.success? && !out.empty? ? out : nil
72
+ rescue Errno::ENOENT
73
+ nil
74
+ end
75
+
76
+ # An image by URL: fetched when allowed, else the caller shows the alt text
77
+ def remote(src)
78
+ return nil unless @remote && src.match?(%r{\Ahttps?://}i)
79
+
80
+ bytes, ext = @remote.call(src)
81
+ place(bytes, ext) if bytes
82
+ end
83
+
84
+ private
85
+
86
+ # @param max_height [Float, nil] share of the page height the picture may take (the layout's default if nil)
87
+ def place(bytes, ext, max_height: nil)
88
+ name = "img-#{@files.size + 1}#{ext}"
89
+ @files[name] = bytes
90
+ "#fit-image(#{Typst.lit(name)}#{", max-height: #{max_height}" if max_height})"
91
+ end
92
+ end
93
+ end
94
+ end