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.
- checksums.yaml +4 -4
- data/Dockerfile +4 -0
- data/docs/configuration.md +5 -0
- data/docs/diagram.md +69 -0
- data/docs/index.md.erb +1 -0
- data/docs/modeling.md +3 -1
- data/docs/pdf-export.md +118 -0
- data/lib/archsight/analysis/typst_renderer.rb +25 -78
- data/lib/archsight/cli.rb +39 -1
- data/lib/archsight/database.rb +4 -3
- data/lib/archsight/diagram/cli.rb +11 -1
- data/lib/archsight/diagram/errors.rb +1 -0
- data/lib/archsight/diagram/graph/attributes.rb +3 -1
- data/lib/archsight/diagram/graph/node.rb +18 -1
- data/lib/archsight/diagram/includes.rb +168 -0
- data/lib/archsight/diagram/layout/expander.rb +75 -3
- data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
- data/lib/archsight/diagram/parser/ast.rb +5 -0
- data/lib/archsight/diagram/parser/table_layout.rb +2 -0
- data/lib/archsight/diagram/parser.rb +58 -16
- data/lib/archsight/diagram.rb +20 -4
- data/lib/archsight/export/confluence/storage.rb +6 -1
- data/lib/archsight/helpers/diagram_blocks.rb +34 -19
- data/lib/archsight/helpers/diagram_includes.rb +67 -0
- data/lib/archsight/helpers.rb +1 -0
- data/lib/archsight/linter.rb +10 -2
- 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/references.rb +4 -3
- data/lib/archsight/resources/application_component.rb +10 -0
- data/lib/archsight/resources/application_service.rb +9 -0
- data/lib/archsight/resources/config.rb +147 -0
- data/lib/archsight/resources/technology_service.rb +9 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/json_helpers.rb +3 -2
- data/lib/archsight/web/api/openapi/spec.yaml +239 -2
- data/lib/archsight/web/api/page_helpers.rb +1 -1
- data/lib/archsight/web/api/pdf_helpers.rb +46 -0
- data/lib/archsight/web/api/routes.rb +34 -3
- data/lib/archsight/web/application.rb +2 -1
- 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-Ca_C8izS.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-yiZbbvAs.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 +37 -17
- data/lib/archsight/web/public/vue/DocPage-C8Ysway-.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-pr2MR6_b.js +0 -35
- data/lib/archsight/web/public/vue/ErrorPage-FAkpW-hn.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-liQJzFyo.js +0 -1
- data/lib/archsight/web/public/vue/HomePage-DYpzSXG8.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
- data/lib/archsight/web/public/vue/KindList-CrgI4qDb.js +0 -1
- data/lib/archsight/web/public/vue/PageView-BIxT_t49.js +0 -1
- data/lib/archsight/web/public/vue/QueryError-ChdyV8B1.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DdtaHY4k.js +0 -2
- data/lib/archsight/web/public/vue/SearchResults-BYRAcK_o.js +0 -1
- data/lib/archsight/web/public/vue/WikiPage-CgvRN6R2.js +0 -3
- 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
|
data/lib/archsight/helpers.rb
CHANGED
|
@@ -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"
|
data/lib/archsight/linter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/archsight/markdown.rb
CHANGED
|
@@ -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
|