archsight 0.4.2 → 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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +4 -0
  3. data/docs/configuration.md +5 -0
  4. data/docs/diagram.md +69 -0
  5. data/docs/index.md.erb +1 -0
  6. data/docs/modeling.md +3 -1
  7. data/docs/pdf-export.md +118 -0
  8. data/lib/archsight/analysis/typst_renderer.rb +25 -78
  9. data/lib/archsight/cli.rb +39 -1
  10. data/lib/archsight/database.rb +4 -3
  11. data/lib/archsight/diagram/cli.rb +11 -1
  12. data/lib/archsight/diagram/errors.rb +1 -0
  13. data/lib/archsight/diagram/graph/attributes.rb +3 -1
  14. data/lib/archsight/diagram/graph/node.rb +18 -1
  15. data/lib/archsight/diagram/includes.rb +168 -0
  16. data/lib/archsight/diagram/layout/expander.rb +75 -3
  17. data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
  18. data/lib/archsight/diagram/parser/ast.rb +5 -0
  19. data/lib/archsight/diagram/parser/table_layout.rb +2 -0
  20. data/lib/archsight/diagram/parser.rb +58 -16
  21. data/lib/archsight/diagram.rb +20 -4
  22. data/lib/archsight/export/confluence/storage.rb +6 -1
  23. data/lib/archsight/helpers/diagram_blocks.rb +34 -19
  24. data/lib/archsight/helpers/diagram_includes.rb +67 -0
  25. data/lib/archsight/helpers.rb +1 -0
  26. data/lib/archsight/linter.rb +10 -2
  27. data/lib/archsight/markdown.rb +17 -1
  28. data/lib/archsight/pdf/cache.rb +41 -0
  29. data/lib/archsight/pdf/compiler.rb +69 -0
  30. data/lib/archsight/pdf/dependency_graph.rb +127 -0
  31. data/lib/archsight/pdf/document.rb +227 -0
  32. data/lib/archsight/pdf/embeds.rb +134 -0
  33. data/lib/archsight/pdf/images.rb +94 -0
  34. data/lib/archsight/pdf/markdown.rb +217 -0
  35. data/lib/archsight/pdf/remote_image.rb +122 -0
  36. data/lib/archsight/pdf/resource_sheet.rb +129 -0
  37. data/lib/archsight/pdf/sample_document.rb +67 -0
  38. data/lib/archsight/pdf/tables.rb +72 -0
  39. data/lib/archsight/pdf/theme.rb +125 -0
  40. data/lib/archsight/pdf/typst/archsight.typ +152 -0
  41. data/lib/archsight/pdf/typst.rb +45 -0
  42. data/lib/archsight/pdf.rb +30 -0
  43. data/lib/archsight/references.rb +4 -3
  44. data/lib/archsight/resources/application_component.rb +10 -0
  45. data/lib/archsight/resources/application_service.rb +9 -0
  46. data/lib/archsight/resources/config.rb +147 -0
  47. data/lib/archsight/resources/technology_service.rb +9 -0
  48. data/lib/archsight/version.rb +1 -1
  49. data/lib/archsight/web/api/json_helpers.rb +3 -2
  50. data/lib/archsight/web/api/openapi/spec.yaml +239 -2
  51. data/lib/archsight/web/api/page_helpers.rb +1 -1
  52. data/lib/archsight/web/api/pdf_helpers.rb +46 -0
  53. data/lib/archsight/web/api/routes.rb +34 -3
  54. data/lib/archsight/web/application.rb +2 -1
  55. data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
  56. data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
  57. data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
  58. data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
  59. data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
  60. data/lib/archsight/web/public/vue/{InstanceRouter-Ca_C8izS.css → InstanceRouter-BCKbW-Wp.css} +1 -1
  61. data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
  62. data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
  63. data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
  64. data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
  65. data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
  66. data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
  67. data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
  68. data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
  69. data/lib/archsight/web/public/vue/{index-yiZbbvAs.js → index-BF4zUlWZ.js} +2 -2
  70. data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
  71. data/lib/archsight/web/public/vue.html +2 -2
  72. metadata +37 -17
  73. data/lib/archsight/web/public/vue/DocPage-C8Ysway-.js +0 -1
  74. data/lib/archsight/web/public/vue/EditorPage-pr2MR6_b.js +0 -35
  75. data/lib/archsight/web/public/vue/ErrorPage-FAkpW-hn.js +0 -2
  76. data/lib/archsight/web/public/vue/GraphView-liQJzFyo.js +0 -1
  77. data/lib/archsight/web/public/vue/HomePage-DYpzSXG8.js +0 -2
  78. data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
  79. data/lib/archsight/web/public/vue/KindList-CrgI4qDb.js +0 -1
  80. data/lib/archsight/web/public/vue/PageView-BIxT_t49.js +0 -1
  81. data/lib/archsight/web/public/vue/QueryError-ChdyV8B1.js +0 -1
  82. data/lib/archsight/web/public/vue/ResourceList-DdtaHY4k.js +0 -2
  83. data/lib/archsight/web/public/vue/SearchResults-BYRAcK_o.js +0 -1
  84. data/lib/archsight/web/public/vue/WikiPage-CgvRN6R2.js +0 -3
  85. data/lib/archsight/web/public/vue/index-BLSE5DSv.css +0 -1
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../assets"
4
+
5
+ module Archsight
6
+ module Helpers
7
+ # Loads the diagrams that `include "<path>"` names (see Archsight::Diagram::Includes):
8
+ #
9
+ # archsight://Kind/name the `architecture/diagram` of that resource
10
+ # file://path/to/x.asd an .asd file, relative to the resources directory (never outside it)
11
+ #
12
+ # #call returns the diagram source, or `:missing` / `:forbidden` / `:invalid`. Without a database only
13
+ # `file://` paths work (the standalone `archsight diagram`).
14
+ class DiagramIncludes
15
+ ARCHSIGHT = "archsight://"
16
+ FILE = "file://"
17
+ ANNOTATION = "architecture/diagram"
18
+ MAX_BYTES = 1_000_000
19
+
20
+ # The loader for a database, whose path is the resources directory
21
+ def self.for(database)
22
+ new(database, resources_dir: (database.path if database.respond_to?(:path)))
23
+ end
24
+
25
+ def initialize(database, resources_dir:)
26
+ @database = database
27
+ @resources_dir = resources_dir
28
+ end
29
+
30
+ def call(path)
31
+ if path.start_with?(ARCHSIGHT)
32
+ resource(path.delete_prefix(ARCHSIGHT))
33
+ elsif path.start_with?(FILE)
34
+ file(path.delete_prefix(FILE))
35
+ else
36
+ :invalid
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # `Kind/name`; names contain `:` but a kind never contains `/`, so the first `/` separates them
43
+ def resource(reference)
44
+ return :forbidden unless @database
45
+
46
+ kind, name = reference.split("/", 2)
47
+ return :invalid if kind.to_s.empty? || name.to_s.empty? || !Archsight::Resources[kind]
48
+
49
+ source = @database.instance_by_kind(kind, name)&.annotations&.fetch(ANNOTATION, nil)
50
+ source.is_a?(String) && !source.strip.empty? ? source : :missing
51
+ end
52
+
53
+ def file(path)
54
+ return :invalid if @resources_dir.nil? || !Archsight::Assets.asd?(path)
55
+ return :forbidden if path.start_with?("/") # file:///etc/x.asd; relative to the resources directory only
56
+
57
+ real = Archsight::Assets.file_for(path, resources_dir: @resources_dir)
58
+ return :missing unless real
59
+ return :forbidden if File.size(real) > MAX_BYTES
60
+
61
+ File.read(real, encoding: "UTF-8")
62
+ rescue SystemCallError
63
+ :missing
64
+ end
65
+ end
66
+ end
67
+ end
@@ -7,6 +7,7 @@ require_relative "helpers/fenced_blocks"
7
7
  require_relative "helpers/view_blocks"
8
8
  require_relative "helpers/requirements_blocks"
9
9
  require_relative "helpers/resource_resolver"
10
+ require_relative "helpers/diagram_includes"
10
11
  require_relative "helpers/embeds"
11
12
  require_relative "helpers/wiki_links"
12
13
  require_relative "helpers/asset_images"
@@ -24,6 +24,7 @@ module Archsight
24
24
  def initialize(database)
25
25
  @database = database
26
26
  @errors = []
27
+ @diagram_warnings = []
27
28
  end
28
29
 
29
30
  def validate
@@ -47,7 +48,7 @@ module Archsight
47
48
  # annotations their kind marks `deprecated:`. They are not errors, so they do not make `archsight lint` fail.
48
49
  def warnings
49
50
  renamed = @database.respond_to?(:deprecations) ? @database.deprecations.map(&:to_s) : []
50
- renamed + deprecated_annotations
51
+ renamed + deprecated_annotations + @diagram_warnings
51
52
  end
52
53
 
53
54
  # Structural modeling checks (rules of "Choosing the Kind", see Linter::Structure). Warnings, never errors.
@@ -402,7 +403,10 @@ module Archsight
402
403
  # Renders with the same resolver the web UI uses, so a `resource` reference that would show as a broken link there is reported here
403
404
  def render_diagram_source(instance, where, source)
404
405
  unresolved = []
405
- Archsight::Diagram.render(source, resolver: Helpers::ResourceResolver.new(@database), unresolved: unresolved)
406
+ warnings = []
407
+ Archsight::Diagram.render(source, resolver: Helpers::ResourceResolver.new(@database), unresolved: unresolved,
408
+ includes: diagram_includes, warnings: warnings)
409
+ warnings.each { |message| @diagram_warnings << "#{instance.path_ref}: Diagram in #{where}: #{message}" }
406
410
  unresolved.each do |u|
407
411
  @errors << "#{instance.path_ref}: Diagram resource link #{u[:reference].inspect} #{u[:reason]} in #{where} (node #{u[:node].inspect}, line #{u[:line]})"
408
412
  end
@@ -410,6 +414,10 @@ module Archsight
410
414
  @errors << "#{instance.path_ref}: Diagram error in #{where}: #{e.message}"
411
415
  end
412
416
 
417
+ def diagram_includes
418
+ @diagram_includes ||= Helpers::DiagramIncludes.for(@database)
419
+ end
420
+
413
421
  def validate_view_fields(instance)
414
422
  check_view_components(instance, instance.annotations["view/fields"].to_s.split(",").map(&:strip))
415
423
  end
@@ -14,9 +14,25 @@ module Archsight
14
14
  # Raw HTML whose text is kept as it is
15
15
  VERBATIM = %w[pre code script style textarea].freeze
16
16
 
17
+ # Fenced code and inline code, which are left as written
18
+ CODE = /(^[ \t]*(?:`{3,}|~{3,}).*?^[ \t]*(?:`{3,}|~{3,})[ \t]*$|`+[^`\n].*?`+)/m
19
+ WIKI_LINK_PIPE = /\[\[[^\]\n]*\]\]/
20
+
17
21
  def self.document(text, **)
18
- Kramdown::Document.new(text.to_s, **OPTIONS, **)
22
+ Kramdown::Document.new(protect_wiki_link_pipes(text.to_s), **OPTIONS, **)
23
+ end
24
+
25
+ # Kramdown reads every line with a `|` as a table row, which would cut `[[Target|label]]` in two (a list item
26
+ # "Related: [[A/B|b]], [[C/D|d]]" became one table row). An escaped pipe is a plain `|` again in the HTML,
27
+ # which is what WikiLinks expects; code is untouched, it would show the backslash.
28
+ def self.protect_wiki_link_pipes(text)
29
+ return text unless text.include?("[[") && text.include?("|")
30
+
31
+ text.split(CODE).each_with_index.map do |part, index|
32
+ index.odd? ? part : part.gsub(WIKI_LINK_PIPE) { |link| link.gsub(/(?<!\\)\|/, "\\\\|") }
33
+ end.join
19
34
  end
35
+ private_class_method :protect_wiki_link_pipes
20
36
 
21
37
  # The document without the newlines of the source in its text, for a target that keeps a newline instead of
22
38
  # reading it as a space (Confluence): a soft line break becomes a space, the newline after a line break goes.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Pdf
5
+ # The last PDFs that were made, by fingerprint of what they were made from, and a limit on how many are made at
6
+ # the same time (typst needs a CPU for seconds, a page nobody changed needs none).
7
+ class Cache
8
+ def initialize(entries: 16, parallel: 2)
9
+ @entries = entries
10
+ @store = {}
11
+ @lock = Mutex.new
12
+ @slots = SizedQueue.new(parallel)
13
+ parallel.times { @slots << true }
14
+ end
15
+
16
+ # @param key [String] fingerprint
17
+ # @yield makes the PDF when it is not cached
18
+ # @return [String]
19
+ def fetch(key, &)
20
+ cached = @lock.synchronize { @store.delete(key)&.tap { |pdf| @store[key] = pdf } }
21
+ return cached if cached
22
+
23
+ pdf = make(&)
24
+ @lock.synchronize do
25
+ @store[key] = pdf
26
+ @store.shift while @store.size > @entries
27
+ end
28
+ pdf
29
+ end
30
+
31
+ private
32
+
33
+ def make
34
+ @slots.pop
35
+ yield
36
+ ensure
37
+ @slots << true
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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