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,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
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../markdown"
|
|
4
|
+
require_relative "../helpers/macros"
|
|
5
|
+
require_relative "../jira_settings"
|
|
6
|
+
require_relative "typst"
|
|
7
|
+
|
|
8
|
+
module Archsight
|
|
9
|
+
module Pdf
|
|
10
|
+
# Markdown (the dialect of Archsight::Markdown) as Typst markup.
|
|
11
|
+
#
|
|
12
|
+
# What a document refers to is resolved by the caller through hooks, so this class knows neither the database
|
|
13
|
+
# nor the file system:
|
|
14
|
+
#
|
|
15
|
+
# - `image`: `(src, alt) -> String | nil`, Typst markup for an embedded image (nil: left out, the alt text is shown)
|
|
16
|
+
# - `diagram`: `(source) -> String`, Typst markup for an `asd` code block (default: the source as code)
|
|
17
|
+
# - `label`: `(target) -> String`, the text of a `[[Target]]` link without label (default: the target)
|
|
18
|
+
# - `embeds`: an object with `view_block(source)`, `requirements_block(source)` and `embed(reference)`, each
|
|
19
|
+
# returning Typst markup (never raising: what cannot be shown is a note). It makes the live content of a page, a
|
|
20
|
+
# ```view or ```requirements block and `![[Kind/Name]]` on a line of its own, part of the document. Without it
|
|
21
|
+
# those blocks are shown as written and an embed is its label.
|
|
22
|
+
class Markdown
|
|
23
|
+
include Typst
|
|
24
|
+
|
|
25
|
+
SYMBOLS = {
|
|
26
|
+
ndash: "–", mdash: "—", hellip: "…", laquo: "«", raquo: "»", laquo_space: "« ", raquo_space: " »",
|
|
27
|
+
lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”"
|
|
28
|
+
}.freeze
|
|
29
|
+
STATUS_FILL = {
|
|
30
|
+
"green" => "#d9f2e0", "yellow" => "#fff3cc", "red" => "#fde8e8", "blue" => "#ddeeff", "grey" => "#e8e8e8",
|
|
31
|
+
"gray" => "#e8e8e8", "purple" => "#eadcf8"
|
|
32
|
+
}.freeze
|
|
33
|
+
EMBED = /\A!\[\[([^\]|]+)\]\]\z/
|
|
34
|
+
# `![[Embed]]`, `[[Target|label]]` and the `{macro:arguments}` of the wiki (see Helpers::Macros)
|
|
35
|
+
INLINE = /(!)?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]|\{([a-z][a-z0-9-]*)(?::([^{}|\n]*))?\}/
|
|
36
|
+
ALIGNMENT = { left: "left", center: "center", right: "right" }.freeze
|
|
37
|
+
|
|
38
|
+
# @param heading_offset [Integer] added to the level of every heading
|
|
39
|
+
def initialize(heading_offset: 0, image: nil, diagram: nil, label: nil, embeds: nil)
|
|
40
|
+
@heading_offset = heading_offset
|
|
41
|
+
@image = image
|
|
42
|
+
@diagram = diagram
|
|
43
|
+
@label = label
|
|
44
|
+
@embeds = embeds
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param inline [Boolean] for a table cell or list item: paragraphs are joined by a line break
|
|
48
|
+
# @return [String] Typst markup
|
|
49
|
+
def render(source, inline: false)
|
|
50
|
+
out = blocks(Archsight::Markdown.document(source).root.children)
|
|
51
|
+
inline ? out.gsub(/\n\n+/, " #linebreak() ") : out
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def blocks(elements)
|
|
57
|
+
elements.filter_map { |node| block(node) }.join("\n\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def block(node)
|
|
61
|
+
case node.type
|
|
62
|
+
when :p then paragraph(node)
|
|
63
|
+
when :header then "#heading(level: #{[node.options[:level] + @heading_offset, 6].min})[#{inlines(node.children)}]"
|
|
64
|
+
when :codeblock then code(node.value, node.options[:lang])
|
|
65
|
+
when :blockquote then "#quote(block: true)[#{blocks(node.children)}]"
|
|
66
|
+
when :ul then "#list(#{items(node)})"
|
|
67
|
+
when :ol then "#enum(#{items(node)})"
|
|
68
|
+
when :hr then "#line(length: 100%, stroke: 0.5pt + luma(200))"
|
|
69
|
+
when :table then table(node)
|
|
70
|
+
when :blank, :html_element, :xml_comment then nil
|
|
71
|
+
else inlines(node.children) unless node.children.empty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A paragraph that is only an image is a figure, centred; one that is only embeds (one per line) is their content
|
|
76
|
+
def paragraph(node)
|
|
77
|
+
content = node.children.reject { |c| c.type == :text && c.value.strip.empty? }
|
|
78
|
+
return image(content.first) if content.size == 1 && content.first.type == :img
|
|
79
|
+
|
|
80
|
+
embeds = block_embeds(content)
|
|
81
|
+
return embeds if embeds
|
|
82
|
+
|
|
83
|
+
inlines(node.children)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def block_embeds(content)
|
|
87
|
+
return nil unless @embeds && content.size == 1 && content.first.type == :text
|
|
88
|
+
|
|
89
|
+
lines = content.first.value.lines.map(&:strip).reject(&:empty?)
|
|
90
|
+
return nil unless lines.any? && lines.all? { |line| line.match?(EMBED) }
|
|
91
|
+
|
|
92
|
+
lines.map { |line| @embeds.embed(line[EMBED, 1].strip) }.join("\n\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def items(list)
|
|
96
|
+
list.children.map { |li| "[#{blocks(li.children)}]" }.join(", ")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def code(source, lang)
|
|
100
|
+
case lang.to_s
|
|
101
|
+
when "asd" then @diagram ? @diagram.call(source) : raw(source, lang)
|
|
102
|
+
when "view" then @embeds ? @embeds.view_block(source) : raw(source, lang)
|
|
103
|
+
when "requirements" then @embeds ? @embeds.requirements_block(source) : raw(source, lang)
|
|
104
|
+
when "mermaid" then [note("Mermaid diagram, shown as a diagram in Archsight", fill: "#f2f2f2"), raw(source, lang)].join("\n\n")
|
|
105
|
+
else raw(source, lang)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def table(node)
|
|
110
|
+
head, body = node.children.partition { |section| section.type == :thead }
|
|
111
|
+
head_rows = head.flat_map(&:children).map { |tr| cells(tr, header: true) }
|
|
112
|
+
body_rows = body.flat_map(&:children).map { |tr| cells(tr) }
|
|
113
|
+
width = (head_rows + body_rows).map(&:size).max || 1
|
|
114
|
+
header = head_rows.empty? ? "" : "table.header(#{head_rows.flatten.join(", ")}), "
|
|
115
|
+
"#table(columns: #{width}, #{align(node, width)}#{header}#{body_rows.flatten.join(", ")})"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def align(node, width)
|
|
119
|
+
alignment = Array(node.options[:alignment])
|
|
120
|
+
return "" unless alignment.any? { |a| ALIGNMENT.key?(a) }
|
|
121
|
+
|
|
122
|
+
"align: (#{Array.new(width) { |i| ALIGNMENT.fetch(alignment[i], "auto") }.join(", ")},), "
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The header cells are semibold, set on the cells (a show rule on the table header does not reach their text)
|
|
126
|
+
def cells(row, header: false)
|
|
127
|
+
row.children.map do |td|
|
|
128
|
+
content = inlines(td.children)
|
|
129
|
+
header ? "[#text(weight: \"semibold\")[#{content}]]" : "[#{content}]"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def inlines(elements)
|
|
134
|
+
elements.map { |node| inline(node) }.join
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def inline(node)
|
|
138
|
+
case node.type
|
|
139
|
+
when :text then text(node.value)
|
|
140
|
+
when :em then "#emph[#{inlines(node.children)}]"
|
|
141
|
+
when :strong then "#strong[#{inlines(node.children)}]"
|
|
142
|
+
when :codespan then "#raw(#{lit(node.value)})"
|
|
143
|
+
when :a then "#link(#{lit(node.attr["href"])})[#{inlines(node.children)}]"
|
|
144
|
+
when :br then "#linebreak()"
|
|
145
|
+
when :img then image(node)
|
|
146
|
+
when :entity then str(node.value.char)
|
|
147
|
+
when :smart_quote, :typographic_sym then str(SYMBOLS.fetch(node.value, node.value.to_s))
|
|
148
|
+
when :html_element then html(node)
|
|
149
|
+
else inlines(node.children)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def html(node)
|
|
154
|
+
return "#linebreak()" if node.value == "br"
|
|
155
|
+
|
|
156
|
+
checkbox(node) || inlines(node.children)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def checkbox(node)
|
|
160
|
+
return unless node.value == "input" && node.attr["type"] == "checkbox"
|
|
161
|
+
|
|
162
|
+
str(node.attr.key?("checked") ? "☑ " : "☐ ")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def image(node)
|
|
166
|
+
src = node.attr["src"].to_s
|
|
167
|
+
alt = node.attr["alt"].to_s
|
|
168
|
+
@image&.call(src, alt) || "#emph[#{str(alt.empty? ? src : alt)}]"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Plain text, in which wiki links, embeds and the macros of the wiki are replaced
|
|
172
|
+
def text(value)
|
|
173
|
+
out = +""
|
|
174
|
+
pos = 0
|
|
175
|
+
value.scan(INLINE) do
|
|
176
|
+
match = Regexp.last_match
|
|
177
|
+
out << str(value[pos...match.begin(0)]) if match.begin(0) > pos
|
|
178
|
+
out << (replacement(match) || str(match[0]))
|
|
179
|
+
pos = match.end(0)
|
|
180
|
+
end
|
|
181
|
+
out << str(value[pos..]) if pos < value.length
|
|
182
|
+
out
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def replacement(match)
|
|
186
|
+
match[2] ? link(match) : macro(match[4], match[5])
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def link(match)
|
|
190
|
+
label = match[3]&.strip || @label&.call(match[2].strip) || match[2].strip
|
|
191
|
+
match[1] ? "#emph[#{str(label)}]" : str(label)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A registered macro with valid arguments; nil leaves the text as written. What needs the running application
|
|
195
|
+
# (`children`, `pagetree`) has no meaning on paper.
|
|
196
|
+
def macro(name, arguments)
|
|
197
|
+
handler = Helpers::Macros.handler(name)
|
|
198
|
+
return nil unless handler
|
|
199
|
+
|
|
200
|
+
value = handler.parse(arguments)
|
|
201
|
+
return nil unless value
|
|
202
|
+
|
|
203
|
+
case name
|
|
204
|
+
when "status" then badge(value.text.upcase, STATUS_FILL.fetch(value.colour.to_s.downcase, "#e8e8e8"))
|
|
205
|
+
when "jira" then jira(value)
|
|
206
|
+
when "emoticon" then str(value.char)
|
|
207
|
+
when "children", "pagetree" then ""
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def jira(key)
|
|
212
|
+
url = JiraSettings.link_for(key)
|
|
213
|
+
url ? "#link(#{lit(url)})[#{str(key)}]" : "#raw(#{lit(key)})"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "resolv"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "../user_config"
|
|
8
|
+
|
|
9
|
+
module Archsight
|
|
10
|
+
module Pdf
|
|
11
|
+
# Images that a page embeds by URL (``), fetched by the server so they can go into the PDF.
|
|
12
|
+
#
|
|
13
|
+
# The URL comes from page content, so the server must not become a way into the network behind it: only http(s) on
|
|
14
|
+
# the default ports, only hosts that resolve to public addresses (checked for the address that is connected to, and
|
|
15
|
+
# again for every redirect), a size and time limit, and the answer must really be a png, jpeg, gif or webp image.
|
|
16
|
+
# Anything else is `nil` and the document shows the alt text. `pdf.remote_images: false`
|
|
17
|
+
# (`ARCHSIGHT_PDF_REMOTE_IMAGES`) turns fetching off.
|
|
18
|
+
module RemoteImage
|
|
19
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
20
|
+
TIMEOUT = 10
|
|
21
|
+
MAX_REDIRECTS = 3
|
|
22
|
+
CACHE_SECONDS = 3600
|
|
23
|
+
CACHE_ENTRIES = 64
|
|
24
|
+
PORTS = { "http" => 80, "https" => 443 }.freeze
|
|
25
|
+
NON_PUBLIC = %w[
|
|
26
|
+
0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16
|
|
27
|
+
198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8
|
|
28
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
29
|
+
TYPES = { png: ".png", jpeg: ".jpg", gif: ".gif", webp: ".webp" }.freeze
|
|
30
|
+
|
|
31
|
+
@cache = {}
|
|
32
|
+
@lock = Mutex.new
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# @return [Boolean] whether fetching is on
|
|
36
|
+
def enabled?(env: ENV)
|
|
37
|
+
!%w[false no 0 off].include?(UserConfig.setting("pdf", "remote_images", env: env).to_s.strip.downcase)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Array(String, String), nil] image bytes and the file extension, nil if it cannot be had
|
|
41
|
+
def fetch(url, resolver: Resolv, now: Time.now)
|
|
42
|
+
cached(url, now) { download(url, resolver) }
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Public addresses only: not loopback, private, link-local (cloud metadata), multicast, reserved or unspecified
|
|
48
|
+
def public_address?(address)
|
|
49
|
+
ip = IPAddr.new(address.to_s)
|
|
50
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
51
|
+
NON_PUBLIC.none? { |range| range.include?(ip) }
|
|
52
|
+
rescue IPAddr::Error
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Symbol, nil] :png, :jpeg, :gif or :webp from the first bytes
|
|
57
|
+
def image_type(bytes)
|
|
58
|
+
head = bytes.byteslice(0, 12).to_s.b
|
|
59
|
+
return :png if head.start_with?("\x89PNG\r\n\x1a\n".b)
|
|
60
|
+
return :jpeg if head.start_with?("\xFF\xD8\xFF".b)
|
|
61
|
+
return :gif if head.start_with?("GIF87a", "GIF89a")
|
|
62
|
+
|
|
63
|
+
:webp if head.start_with?("RIFF") && head.byteslice(8, 4) == "WEBP"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def cached(url, now)
|
|
69
|
+
entry = @lock.synchronize { @cache[url] }
|
|
70
|
+
return entry[:value] if entry && now - entry[:at] < CACHE_SECONDS
|
|
71
|
+
|
|
72
|
+
value = yield
|
|
73
|
+
@lock.synchronize do
|
|
74
|
+
@cache[url] = { value: value, at: now }
|
|
75
|
+
@cache.shift while @cache.size > CACHE_ENTRIES
|
|
76
|
+
end
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def download(url, resolver, redirects = 0)
|
|
81
|
+
uri = URI.parse(url)
|
|
82
|
+
return nil unless PORTS.key?(uri.scheme) && uri.host && uri.port == PORTS[uri.scheme] && uri.userinfo.nil?
|
|
83
|
+
|
|
84
|
+
addresses = resolver.getaddresses(uri.host).map(&:to_s)
|
|
85
|
+
return nil if addresses.empty? || !addresses.all? { |a| public_address?(a) }
|
|
86
|
+
|
|
87
|
+
response, body = get(uri, addresses.first)
|
|
88
|
+
case response
|
|
89
|
+
when Net::HTTPSuccess then image(body)
|
|
90
|
+
when Net::HTTPRedirection
|
|
91
|
+
redirects < MAX_REDIRECTS ? download(URI.join(url, response["location"].to_s).to_s, resolver, redirects + 1) : nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Connects to the address that was checked (not to a second DNS answer); the certificate is checked for the name.
|
|
96
|
+
# @return [Array(Net::HTTPResponse, String), nil] the response and its body, nil if the body is too large
|
|
97
|
+
def get(uri, address)
|
|
98
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
99
|
+
http.ipaddr = address
|
|
100
|
+
http.use_ssl = uri.scheme == "https"
|
|
101
|
+
http.open_timeout = http.read_timeout = http.write_timeout = TIMEOUT
|
|
102
|
+
http.request(Net::HTTP::Get.new(uri.request_uri, "Accept" => "image/*", "User-Agent" => "archsight-pdf")) do |res|
|
|
103
|
+
return [res, nil] unless res.is_a?(Net::HTTPSuccess)
|
|
104
|
+
return nil if res["Content-Length"].to_i > MAX_BYTES
|
|
105
|
+
|
|
106
|
+
body = +"".b
|
|
107
|
+
res.read_body do |chunk|
|
|
108
|
+
body << chunk
|
|
109
|
+
return nil if body.bytesize > MAX_BYTES
|
|
110
|
+
end
|
|
111
|
+
return [res, body]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def image(bytes)
|
|
116
|
+
type = image_type(bytes.to_s)
|
|
117
|
+
type ? [bytes, TYPES.fetch(type)] : nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|