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,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
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "typst"
|
|
4
|
+
|
|
5
|
+
module Archsight
|
|
6
|
+
module Pdf
|
|
7
|
+
# The facts of a resource that is not a page, as Typst markup: its properties (annotations) and its relations in
|
|
8
|
+
# both directions. Structure comes from alignment and light rules, not from boxes: a muted label column, rows
|
|
9
|
+
# divided by hairlines, related annotations kept together, and the resources of a relation flowing in columns
|
|
10
|
+
# instead of one long comma-separated line.
|
|
11
|
+
class ResourceSheet
|
|
12
|
+
SKIP = %w[architecture/description architecture/diagram architecture/tags].freeze
|
|
13
|
+
LONG = 400 # characters; longer single-line values still sit in the table, wrapped, up to MAX
|
|
14
|
+
MAX = 2000
|
|
15
|
+
RULE = "0.4pt + theme.muted.lighten(75%)" # as the rule under the rows of every other table
|
|
16
|
+
NAMES_IN_COLUMNS = 4 # from this many resources a relation lists them in two columns
|
|
17
|
+
|
|
18
|
+
# @param markdown [#call] `(String) -> Typst`, renders markdown the way the document does
|
|
19
|
+
def initialize(resource, markdown:)
|
|
20
|
+
@resource = resource
|
|
21
|
+
@markdown = markdown
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] the sections (heading and content) of the properties, none if there are none
|
|
25
|
+
def properties
|
|
26
|
+
short, long = annotations.partition { |key, value| table_value?(key, value) }
|
|
27
|
+
parts = []
|
|
28
|
+
parts << "#heading(level: 1)[#{Typst.str("Properties")}]\n\n#{property_table(short)}" unless short.empty?
|
|
29
|
+
long.each { |key, value| parts << long_value(key, value) }
|
|
30
|
+
parts
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>] the sections of the outgoing and the incoming relations
|
|
34
|
+
def relations
|
|
35
|
+
out = outgoing
|
|
36
|
+
incoming = self.incoming
|
|
37
|
+
return [] if out.empty? && incoming.empty?
|
|
38
|
+
|
|
39
|
+
parts = ["#heading(level: 1)[#{Typst.str("Relations")}]"]
|
|
40
|
+
parts << "#heading(level: 2)[#{Typst.str("Outgoing")}]\n\n#{relation_table(out)}" unless out.empty?
|
|
41
|
+
parts << "#heading(level: 2)[#{Typst.str("Incoming")}]\n\n#{caption("Resources that point to this one; the relation is read from their side.")}\n\n#{relation_table(incoming)}" unless incoming.empty?
|
|
42
|
+
parts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `dependsOn` -> "depends on"
|
|
46
|
+
def self.humanize(verb)
|
|
47
|
+
verb.to_s.gsub(/([a-z0-9])([A-Z])/, '\1 \2').downcase
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def klass = @resource.class
|
|
53
|
+
|
|
54
|
+
def annotations
|
|
55
|
+
@resource.annotations.reject { |key, value| SKIP.include?(key) || value.to_s.strip.empty? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Multi-line and markdown values are text, not table cells
|
|
59
|
+
def table_value?(key, value)
|
|
60
|
+
!value.to_s.include?("\n") && !%w[markdown multiline].include?(klass.annotation_format(key).to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def display(key, value)
|
|
64
|
+
text = value.to_s
|
|
65
|
+
text = text.split(",").map(&:strip).reject(&:empty?).join(", ") if klass.annotation_matching(key)&.list?
|
|
66
|
+
text.length > MAX ? "#{text[0, MAX]}…" : text
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Annotations of one namespace (`git/...`, `lifecycle/...`) stay together, separated from the next by a gap
|
|
70
|
+
def property_table(pairs)
|
|
71
|
+
grouped = pairs.group_by { |key, _| key.split("/").first }.values
|
|
72
|
+
cells = grouped.each_with_index.flat_map do |group, index|
|
|
73
|
+
gap = index.zero? ? [] : ["table.cell(colspan: 2, stroke: none, inset: 3pt)[]"]
|
|
74
|
+
gap + group.flat_map do |key, value|
|
|
75
|
+
["[#text(fill: theme.muted)[#{Typst.str(klass.annotation_title(key))}]]", "[#{Typst.str(display(key, value))}]"]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
"#table(columns: (30%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def long_value(key, value)
|
|
82
|
+
body = klass.annotation_format(key).to_s == "markdown" ? @markdown.call(value.to_s) : multiline(value.to_s)
|
|
83
|
+
"#heading(level: 2)[#{Typst.str(klass.annotation_title(key))}]\n\n#{body}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def multiline(text)
|
|
87
|
+
text.lines.map { |line| Typst.str(line.chomp) }.join("#linebreak()")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# { verb => { kind => [instances] } }
|
|
91
|
+
def outgoing = @resource.relations_grouped
|
|
92
|
+
|
|
93
|
+
# incoming comes as { kind => { verb => [instances] } }, shown like the outgoing ones
|
|
94
|
+
def incoming
|
|
95
|
+
@resource.references_grouped.each_with_object({}) do |(kind, verbs), by_verb|
|
|
96
|
+
verbs.each { |verb, instances| (by_verb[verb] ||= {})[kind] = instances }
|
|
97
|
+
end.sort.to_h
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The relation (said once for all of its kinds), the kind, and the names of the resources in one or two columns
|
|
101
|
+
def relation_table(by_verb)
|
|
102
|
+
cells = by_verb.flat_map do |verb, kinds|
|
|
103
|
+
kinds.each_with_index.flat_map do |(kind, instances), index|
|
|
104
|
+
row = []
|
|
105
|
+
row << "table.cell(rowspan: #{kinds.size})[#text(weight: \"semibold\")[#{Typst.str(self.class.humanize(verb))}]]" if index.zero?
|
|
106
|
+
row << "[#pad(right: 8pt, text(fill: theme.muted)[#{Typst.str(breakable(kind))}])]"
|
|
107
|
+
row << "[#{names(instances)}]"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
"#table(columns: (17%, 24%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def names(instances)
|
|
114
|
+
list = instances.map(&:name).uniq.sort.map { |name| "[#{Typst.str(breakable(name))}]" }
|
|
115
|
+
columns = list.size >= NAMES_IN_COLUMNS ? "1fr, 1fr" : "1fr"
|
|
116
|
+
"#grid(columns: (#{columns},), column-gutter: 10pt, row-gutter: 4pt, #{list.join(", ")})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Names have no spaces: allow a line break after each `:` and between camel-case words (no visible change)
|
|
120
|
+
def breakable(name)
|
|
121
|
+
name.gsub(":", ":\u200B").gsub(/([a-z0-9])([A-Z])/, "\\1\u200B\\2")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def caption(text)
|
|
125
|
+
"#text(size: 8.5pt, fill: theme.muted)[#{Typst.str(text)}]"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document"
|
|
4
|
+
|
|
5
|
+
module Archsight
|
|
6
|
+
module Pdf
|
|
7
|
+
# A fixed sample document in the look of one pdf Config, to see what the settings do without exporting a real page:
|
|
8
|
+
# a cover page and a page with the elements a document is made of (headings, text, list, quote, table with
|
|
9
|
+
# status, code). The content is neutral on purpose; only the Config shapes the result. The "resource" of the document
|
|
10
|
+
# is the Config (an Archsight::Resources::Config of type pdf).
|
|
11
|
+
class SampleDocument < Document
|
|
12
|
+
PARTS = %w[cover page].freeze
|
|
13
|
+
TITLE = "Sample document"
|
|
14
|
+
BODY = <<~MD
|
|
15
|
+
## Summary
|
|
16
|
+
|
|
17
|
+
This page shows how headings, text, lists and tables look with the settings of the Config. It is a fixed sample,
|
|
18
|
+
not one of your documents, so every change of a colour, a font or the logo is visible at once.
|
|
19
|
+
|
|
20
|
+
> A quote stands out from the text around it and is set in the same typeface.
|
|
21
|
+
|
|
22
|
+
### What a document contains
|
|
23
|
+
|
|
24
|
+
- Text with **strong** and *emphasised* words, `inline code` and a [link](https://example.com)
|
|
25
|
+
- Lists, tables, diagrams and code, set in the fonts of the Config
|
|
26
|
+
- Status badges such as {status:green Active} and {status:yellow Planned}
|
|
27
|
+
|
|
28
|
+
## Components
|
|
29
|
+
|
|
30
|
+
| Component | Owner | Status |
|
|
31
|
+
|:----------|:------|:-------|
|
|
32
|
+
| Billing API | Payments | {status:green Active} |
|
|
33
|
+
| Export service | Data | {status:yellow Deprecated} |
|
|
34
|
+
| Legacy gateway | Platform | {status:red End of life} |
|
|
35
|
+
|
|
36
|
+
### Interface
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
def total(items)
|
|
40
|
+
items.sum(&:price)
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
MD
|
|
44
|
+
|
|
45
|
+
# @param part [String, nil] `cover` or `page` for just that page (a document of one page, so a viewer needs no
|
|
46
|
+
# navigation), else both
|
|
47
|
+
def initialize(database, config, part: nil, **)
|
|
48
|
+
@part = part if PARTS.include?(part)
|
|
49
|
+
super(database, config, **)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def filename = "#{["preview", super.delete_suffix(".pdf"), @part].compact.join("-")}.pdf"
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def theme_for(_template) = Theme.new(@resource, resources_dir: @resources_dir)
|
|
57
|
+
def title_for = TITLE
|
|
58
|
+
def tag_list = @part == "page" ? [] : %w[pdf:cover]
|
|
59
|
+
def body(images) = @part == "cover" ? "" : markdown(images).render(BODY)
|
|
60
|
+
def doc_id = "SAMPLE-001"
|
|
61
|
+
def version = "1.0"
|
|
62
|
+
def owner = "Alex Example"
|
|
63
|
+
def kind = "Sample"
|
|
64
|
+
def classification = @theme.classification
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "typst"
|
|
4
|
+
require_relative "markdown"
|
|
5
|
+
require_relative "../view_table"
|
|
6
|
+
require_relative "../export/confluence/tables"
|
|
7
|
+
|
|
8
|
+
module Archsight
|
|
9
|
+
module Pdf
|
|
10
|
+
# A ViewTable::Table (the result of a view, of requirements or of an analysis) as a Typst table. Views are live
|
|
11
|
+
# in Archsight; in the PDF they are the data of the moment of the export.
|
|
12
|
+
module Tables
|
|
13
|
+
# A column whose longest text is longer than this shares the width (`fr`), shorter ones take what they need
|
|
14
|
+
LONG = 30
|
|
15
|
+
SMALL_FONT_FROM = 4 # columns
|
|
16
|
+
Confluence = Export::Confluence::Tables
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param table [Archsight::ViewTable::Table]
|
|
21
|
+
# @param markdown [#call] Typst markup of a markdown text, for the cells of `as: :markdown`
|
|
22
|
+
# @param empty [String] shown instead of the table when there are no rows
|
|
23
|
+
# @return [String] Typst markup
|
|
24
|
+
def typst(table, markdown:, empty: "No results")
|
|
25
|
+
parts = []
|
|
26
|
+
parts << heading(table) unless table.title.to_s.empty?
|
|
27
|
+
parts << (table.rows.empty? ? Typst.note(empty, fill: "#f2f2f2") : grid(table, markdown))
|
|
28
|
+
parts << "#emph[#{Typst.str("#{table.cut} more #{table.cut == 1 ? "row" : "rows"} not shown, see Archsight")}]" if table.cut.positive?
|
|
29
|
+
parts.join("\n\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def heading(table)
|
|
33
|
+
count = "(#{table.total} #{table.total == 1 ? "item" : "items"})"
|
|
34
|
+
"#block(sticky: true)[#strong[#{Typst.str(table.title)}] #text(fill: luma(120))[#{Typst.str(count)}]]"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def grid(table, markdown)
|
|
38
|
+
head = table.columns.map { |c| "[#text(weight: \"semibold\")[#{Typst.str(c)}]]" }
|
|
39
|
+
cells = table.rows.flat_map { |row| row.map { |cell| "[#{cell(cell, markdown)}]" } }
|
|
40
|
+
out = "#table(columns: (#{widths(table).join(", ")},), table.header(#{head.join(", ")}), #{cells.join(", ")})"
|
|
41
|
+
table.columns.size >= SMALL_FONT_FROM ? "#[#set text(size: 8.5pt)\n#{out}]" : out
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `auto` for short columns, a share of the rest for long ones, so wide tables wrap instead of running off the page
|
|
45
|
+
def widths(table)
|
|
46
|
+
table.columns.each_index.map do |i|
|
|
47
|
+
longest = [table.columns[i].length, *table.rows.map { |row| row[i] ? text_of(row[i]).length : 0 }].max
|
|
48
|
+
longest > LONG ? "#{(longest / LONG.to_f).round.clamp(1, 4)}fr" : "auto"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def text_of(cell)
|
|
53
|
+
cell.resources.empty? ? cell.text.to_s : cell.resources.map(&:name).join(" ")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cell(cell, markdown)
|
|
57
|
+
return cell.resources.map { |resource| Typst.str(resource.name) }.join("#linebreak()") unless cell.resources.empty?
|
|
58
|
+
|
|
59
|
+
case cell.as
|
|
60
|
+
when :status then badge(cell.text, Confluence::STATUS_COLOURS)
|
|
61
|
+
when :priority then cell.text.to_s.empty? ? "" : badge(cell.text, Confluence::PRIORITY_COLOURS)
|
|
62
|
+
when :markdown then markdown.call(cell.text.to_s)
|
|
63
|
+
else cell.text.to_s.split("\n").map { |line| Typst.str(line) }.join("#linebreak()")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def badge(text, colours)
|
|
68
|
+
Typst.badge(text, Markdown::STATUS_FILL.fetch(colours.fetch(text, "grey"), "#e8e8e8"))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|