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.
- checksums.yaml +4 -4
- data/Dockerfile +4 -0
- data/docs/configuration.md +5 -0
- data/docs/diagram.md +5 -0
- data/docs/index.md.erb +1 -0
- data/docs/pdf-export.md +118 -0
- data/lib/archsight/analysis/typst_renderer.rb +25 -78
- data/lib/archsight/cli.rb +37 -0
- data/lib/archsight/diagram/graph/attributes.rb +3 -1
- data/lib/archsight/diagram/graph/node.rb +18 -1
- data/lib/archsight/diagram/layout/expander.rb +75 -3
- data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
- data/lib/archsight/markdown.rb +17 -1
- data/lib/archsight/pdf/cache.rb +41 -0
- data/lib/archsight/pdf/compiler.rb +69 -0
- data/lib/archsight/pdf/dependency_graph.rb +127 -0
- data/lib/archsight/pdf/document.rb +227 -0
- data/lib/archsight/pdf/embeds.rb +134 -0
- data/lib/archsight/pdf/images.rb +94 -0
- data/lib/archsight/pdf/markdown.rb +217 -0
- data/lib/archsight/pdf/remote_image.rb +122 -0
- data/lib/archsight/pdf/resource_sheet.rb +129 -0
- data/lib/archsight/pdf/sample_document.rb +67 -0
- data/lib/archsight/pdf/tables.rb +72 -0
- data/lib/archsight/pdf/theme.rb +125 -0
- data/lib/archsight/pdf/typst/archsight.typ +152 -0
- data/lib/archsight/pdf/typst.rb +45 -0
- data/lib/archsight/pdf.rb +30 -0
- data/lib/archsight/resources/config.rb +147 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/openapi/spec.yaml +230 -0
- data/lib/archsight/web/api/pdf_helpers.rb +46 -0
- data/lib/archsight/web/api/routes.rb +30 -0
- data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
- data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
- data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
- data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
- data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
- data/lib/archsight/web/public/vue/{InstanceRouter-CJSkGdBG.css → InstanceRouter-BCKbW-Wp.css} +1 -1
- data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
- data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
- data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
- data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
- data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
- data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
- data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
- data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
- data/lib/archsight/web/public/vue/{index-DMhBow5t.js → index-BF4zUlWZ.js} +2 -2
- data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
- data/lib/archsight/web/public/vue.html +2 -2
- metadata +35 -17
- data/lib/archsight/web/public/vue/DocPage-L0GzDFiV.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-B3iNbh1u.js +0 -35
- data/lib/archsight/web/public/vue/ErrorPage-Lro5vwhy.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-BRKwxa--.js +0 -1
- data/lib/archsight/web/public/vue/HomePage-DKgI1DXr.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-CX6qxVTm.js +0 -1
- data/lib/archsight/web/public/vue/KindList-C556gf23.js +0 -1
- data/lib/archsight/web/public/vue/PageView-DAqHjL1x.js +0 -1
- data/lib/archsight/web/public/vue/QueryError-DvspMrsh.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-BZOYa68X.js +0 -2
- data/lib/archsight/web/public/vue/SearchResults-DaaOPG3t.js +0 -1
- data/lib/archsight/web/public/vue/WikiPage-Bfx92Iig.js +0 -3
- 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
|