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,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "typst"
|
|
4
|
+
require_relative "../assets"
|
|
5
|
+
|
|
6
|
+
module Archsight
|
|
7
|
+
module Pdf
|
|
8
|
+
# The look of an exported document: what a Config resource of type pdf says, with a neutral built-in look for
|
|
9
|
+
# everything it leaves out (or for no template at all). Every value is checked here, so a template loaded
|
|
10
|
+
# from a file that was never linted cannot put anything but a colour, a font name or a length into the
|
|
11
|
+
# Typst source.
|
|
12
|
+
class Theme
|
|
13
|
+
DEFAULT_NAME = "default"
|
|
14
|
+
TYPE = "pdf" # the `config/type` of the Configs that describe the look
|
|
15
|
+
COLORS = {
|
|
16
|
+
primary: "#1f3a5f", accent: "#2f7fd1", text: "#1c1c1c", muted: "#6b7280",
|
|
17
|
+
ok: "#1f9d55", warn: "#d98e04", danger: "#c0392b"
|
|
18
|
+
}.freeze
|
|
19
|
+
FONTS = {
|
|
20
|
+
body: ["Liberation Sans", "DejaVu Sans", "Helvetica", "Arial"],
|
|
21
|
+
heading: ["Liberation Sans", "DejaVu Sans", "Helvetica", "Arial"],
|
|
22
|
+
mono: ["DejaVu Sans Mono", "Liberation Mono", "Menlo", "Courier New"]
|
|
23
|
+
}.freeze
|
|
24
|
+
PAPERS = %w[a4 a5 us-letter].freeze
|
|
25
|
+
LANGS = %w[en de].freeze
|
|
26
|
+
HEADER_PARTS = %w[id version date owner].freeze
|
|
27
|
+
DEFAULT_MARGIN = "16mm"
|
|
28
|
+
LOGO_TYPES = %w[.svg .png].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :name, :colors, :fonts, :footer_text, :classification, :header_meta, :lang, :paper, :margin,
|
|
31
|
+
:logo, :logo_light, :font_dir
|
|
32
|
+
|
|
33
|
+
# @param template [Archsight::Resources::Config, nil]
|
|
34
|
+
# @param resources_dir [String] logos and fonts are looked up below it
|
|
35
|
+
def initialize(template = nil, resources_dir: Archsight.resources_dir)
|
|
36
|
+
@name = template&.name
|
|
37
|
+
ann = template ? template.annotations : {}
|
|
38
|
+
@colors = COLORS.to_h { |key, default| [key, color(ann["pdf/color-#{key}"], default)] }
|
|
39
|
+
@fonts = FONTS.to_h { |key, default| [key, font_list(ann["pdf/font-#{key}"], default)] }
|
|
40
|
+
@footer_text = blank_to_nil(ann["pdf/footer-text"])
|
|
41
|
+
@classification = blank_to_nil(ann["pdf/classification"])
|
|
42
|
+
@header_meta = header_parts(ann["pdf/header-meta"])
|
|
43
|
+
@lang = LANGS.include?(ann["pdf/lang"]) ? ann["pdf/lang"] : "en"
|
|
44
|
+
@paper = PAPERS.include?(ann["pdf/paper"]) ? ann["pdf/paper"] : "a4"
|
|
45
|
+
@margin = ann["pdf/margin"].to_s.match?(Resources::Config::LENGTH) ? ann["pdf/margin"] : DEFAULT_MARGIN
|
|
46
|
+
@logo = logo_file(ann["pdf/logo"], resources_dir)
|
|
47
|
+
@logo_light = logo_file(ann["pdf/logo-light"], resources_dir) || @logo
|
|
48
|
+
@font_dir = font_directory(ann["pdf/font-dir"], resources_dir)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The Config a document asks for (`pdf:template:<name>`), else the one named `default`, else the built-in look. Only
|
|
52
|
+
# Configs of type pdf count.
|
|
53
|
+
# @param requested [String, nil] name as written in the tag, with or without the `Config:` and `PDF:` prefixes
|
|
54
|
+
def self.for(database, requested = nil)
|
|
55
|
+
wanted = (requested || DEFAULT_NAME).to_s.strip
|
|
56
|
+
configs = database.instances_by_kind("Config").values.select { |c| c.annotations["config/type"].to_s.casecmp?(TYPE) }
|
|
57
|
+
new(configs.find { |config| matches?(config.name, wanted) })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `brand`, `PDF:brand` and `Config:PDF:brand` all name the same Config
|
|
61
|
+
def self.matches?(name, wanted)
|
|
62
|
+
short(name).casecmp?(short(wanted))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.short(name)
|
|
66
|
+
name.to_s.strip.sub(/\AConfig:/i, "").sub(/\APDF:/i, "")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Typst source defining `theme`. `logo` and `logo_light` are the file names the logos were staged under.
|
|
70
|
+
def to_typst(logo: nil, logo_light: nil)
|
|
71
|
+
fields = colors.map { |key, hex| "#{key}: #{Typst.color(hex)}" }
|
|
72
|
+
fields += fonts.map { |key, list| "font-#{key}: (#{list.map { |f| Typst.lit(f) }.join(", ")},)" }
|
|
73
|
+
fields += [
|
|
74
|
+
"logo: #{logo ? Typst.lit(logo) : "none"}", "logo-light: #{logo_light ? Typst.lit(logo_light) : "none"}",
|
|
75
|
+
"footer-text: #{footer_text ? Typst.lit(footer_text) : "none"}",
|
|
76
|
+
"header-meta: (#{header_meta.map { |p| Typst.lit(p) }.join(", ")},)",
|
|
77
|
+
"lang: #{Typst.lit(lang)}", "paper: #{Typst.lit(paper)}", "margin: #{margin}"
|
|
78
|
+
]
|
|
79
|
+
"#let theme = (#{fields.join(", ")})\n"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def color(value, default)
|
|
85
|
+
value.to_s.strip.match?(Resources::Config::COLOR) ? value.to_s.strip : default
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def font_list(value, default)
|
|
89
|
+
name = value.to_s.strip
|
|
90
|
+
name.match?(Resources::Config::FONT_NAME) ? [name, *default] : default
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def header_parts(value)
|
|
94
|
+
return HEADER_PARTS if value.to_s.strip.empty?
|
|
95
|
+
|
|
96
|
+
parts = value.to_s.split(",").map(&:strip) & HEADER_PARTS
|
|
97
|
+
parts.empty? ? HEADER_PARTS : parts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def blank_to_nil(value)
|
|
101
|
+
text = value.to_s.strip
|
|
102
|
+
text.empty? ? nil : text
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [String, nil] absolute path of a logo inside the resources directory
|
|
106
|
+
def logo_file(path, resources_dir)
|
|
107
|
+
return nil if path.to_s.strip.empty?
|
|
108
|
+
|
|
109
|
+
file = Assets.file_for(path.to_s, resources_dir: resources_dir)
|
|
110
|
+
file if file && LOGO_TYPES.include?(File.extname(file).downcase)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def font_directory(path, resources_dir)
|
|
114
|
+
normalized = Assets.normalize(path.to_s)
|
|
115
|
+
return nil unless normalized
|
|
116
|
+
|
|
117
|
+
root = File.realpath(resources_dir)
|
|
118
|
+
dir = File.realpath(File.join(root, normalized))
|
|
119
|
+
dir if dir.start_with?(root + File::SEPARATOR) && File.directory?(dir)
|
|
120
|
+
rescue SystemCallError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Page shell of the Archsight PDF export. Generic: every colour, font, logo and text comes from `theme`
|
|
2
|
+
// (see Archsight::Pdf::Theme), every per-document value is an argument.
|
|
3
|
+
|
|
4
|
+
#let labels = (
|
|
5
|
+
en: (page: "Page", of: "of", contents: "Contents", draft: "DRAFT", doc-id: "Document ID", version: "Version", date: "Date", owner: "Owner", classification: "Classification"),
|
|
6
|
+
de: (page: "Seite", of: "von", contents: "Inhalt", draft: "ENTWURF", doc-id: "Dokument-ID", version: "Version", date: "Datum", owner: "Eigentümer", classification: "Einstufung"),
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Identifiers have no spaces: allow a line break after `-`, `:` and `/` and between camel-case words (no visible change)
|
|
10
|
+
#let breakable(s) = if type(s) != str { s } else {
|
|
11
|
+
s.replace(regex("([:/-])"), m => m.text + "\u{200B}").replace(regex("([a-z0-9])([A-Z])"), m => m.captures.at(0) + "\u{200B}" + m.captures.at(1))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// An image at its natural size, shrunk to fit the text width and `max-height` of the page, centred
|
|
15
|
+
#let fit-image(path, max-height: 0.5) = layout(size => context {
|
|
16
|
+
let natural = measure(image(path))
|
|
17
|
+
let limit = page.height * max-height
|
|
18
|
+
let w = calc.min(natural.width, size.width)
|
|
19
|
+
let h = w * (natural.height / natural.width)
|
|
20
|
+
if h > limit {
|
|
21
|
+
h = limit
|
|
22
|
+
w = h * (natural.width / natural.height)
|
|
23
|
+
}
|
|
24
|
+
align(center, image(path, width: w, height: h))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// The meta line of the header: the document id in the mono font, the parts separated by a light bar
|
|
28
|
+
#let _meta-line(theme, id, version, date, owner) = {
|
|
29
|
+
let t = labels.at(theme.lang)
|
|
30
|
+
let parts = ()
|
|
31
|
+
if "id" in theme.header-meta and id != none { parts.push(text(font: theme.font-mono, breakable(id))) }
|
|
32
|
+
if "version" in theme.header-meta and version != none { parts.push(box[#t.version #version]) }
|
|
33
|
+
if "date" in theme.header-meta and date != none { parts.push(box[#date]) }
|
|
34
|
+
if "owner" in theme.header-meta and owner != none { parts.push(box[#owner]) }
|
|
35
|
+
parts.join(h(4pt) + text(fill: theme.muted.lighten(60%), "|") + h(4pt))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The document shell: page layout, header, footer, headings, watermark.
|
|
39
|
+
#let doc(
|
|
40
|
+
theme: none,
|
|
41
|
+
title: "",
|
|
42
|
+
doc-id: none,
|
|
43
|
+
version: none,
|
|
44
|
+
date: none,
|
|
45
|
+
owner: none,
|
|
46
|
+
classification: none,
|
|
47
|
+
draft: false,
|
|
48
|
+
landscape: false,
|
|
49
|
+
body,
|
|
50
|
+
) = {
|
|
51
|
+
let t = labels.at(theme.lang)
|
|
52
|
+
set document(title: title)
|
|
53
|
+
set page(
|
|
54
|
+
paper: theme.paper,
|
|
55
|
+
flipped: landscape,
|
|
56
|
+
margin: (x: theme.margin, top: 22mm, bottom: 16mm),
|
|
57
|
+
header-ascent: 30%,
|
|
58
|
+
footer-descent: 35%,
|
|
59
|
+
header: grid(
|
|
60
|
+
columns: (1fr, auto),
|
|
61
|
+
align: (left + bottom, right + bottom),
|
|
62
|
+
text(size: 7pt, fill: theme.muted.lighten(30%), _meta-line(theme, doc-id, version, date, owner)),
|
|
63
|
+
if theme.logo != none { image(theme.logo, height: 6.5mm) },
|
|
64
|
+
),
|
|
65
|
+
footer: {
|
|
66
|
+
set text(size: 7pt, fill: theme.muted)
|
|
67
|
+
line(length: 100%, stroke: 0.5pt + theme.muted.lighten(70%))
|
|
68
|
+
v(-2pt)
|
|
69
|
+
grid(
|
|
70
|
+
columns: (1fr, auto, 1fr),
|
|
71
|
+
column-gutter: 8pt,
|
|
72
|
+
align: (left + horizon, center + horizon, right + horizon),
|
|
73
|
+
if classification != none { text(weight: "bold", tracking: 0.06em, fill: theme.danger, classification) },
|
|
74
|
+
if theme.footer-text != none { text(style: "italic", theme.footer-text) },
|
|
75
|
+
context [#t.page #counter(page).display() #t.of #counter(page).final().first()],
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
background: if draft {
|
|
79
|
+
rotate(-45deg, text(size: 110pt, weight: "bold", fill: theme.muted.transparentize(85%), t.draft))
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
set text(font: theme.font-body, size: 10pt, fill: theme.text, lang: theme.lang)
|
|
83
|
+
set par(justify: false, leading: 0.62em)
|
|
84
|
+
show raw: set text(font: theme.font-mono, size: 8.5pt)
|
|
85
|
+
show raw.where(block: true): it => block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%, it)
|
|
86
|
+
show heading: set text(font: theme.font-heading, fill: theme.primary)
|
|
87
|
+
show heading.where(level: 1): set text(size: 16pt)
|
|
88
|
+
show heading.where(level: 2): set text(size: 13pt)
|
|
89
|
+
show heading.where(level: 3): set text(size: 11pt)
|
|
90
|
+
show link: set text(fill: theme.accent)
|
|
91
|
+
// Tables have horizontal rules only: a stronger one under the header, a light one under every other row, no frame and
|
|
92
|
+
// no vertical lines; the first column is flush with the text. Tables that pass their own stroke keep it.
|
|
93
|
+
set table(
|
|
94
|
+
stroke: (x, y) => if y == 0 { (bottom: 0.7pt + theme.muted.lighten(35%)) } else { (bottom: 0.4pt + theme.muted.lighten(75%)) },
|
|
95
|
+
inset: (x, y) => (left: if x == 0 { 0pt } else { 6pt }, right: 6pt, y: 6pt),
|
|
96
|
+
)
|
|
97
|
+
set table.cell(breakable: false)
|
|
98
|
+
show table.header: set text(weight: "semibold")
|
|
99
|
+
body
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Title with the kind at its right, a hairline directly below, then an optional intro
|
|
103
|
+
#let title-block(theme: none, title: "", kind: none, intro: none) = {
|
|
104
|
+
grid(
|
|
105
|
+
columns: (1fr, auto),
|
|
106
|
+
align: (left + bottom, right + bottom),
|
|
107
|
+
text(font: theme.font-heading, size: 22pt, weight: "bold", fill: theme.primary, breakable(title)),
|
|
108
|
+
if kind != none { text(size: 8pt, fill: theme.muted, kind) },
|
|
109
|
+
)
|
|
110
|
+
v(-4pt)
|
|
111
|
+
line(length: 100%, stroke: 0.5pt + theme.muted.lighten(70%))
|
|
112
|
+
if intro != none { block(above: 8pt, below: 6pt, text(size: 9pt, fill: theme.muted, intro)) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A cover page without header and footer: a band with the logo at its bottom left, kind, title and a short rule, the
|
|
116
|
+
// document meta at the bottom left of the page. The pages after it are numbered from 1.
|
|
117
|
+
#let cover-page(theme: none, title: "", kind: none, doc-id: none, version: none, date: none, owner: none, classification: none) = {
|
|
118
|
+
let t = labels.at(theme.lang)
|
|
119
|
+
page(header: none, footer: none, margin: 0mm, background: none)[
|
|
120
|
+
#block(width: 100%, height: 52mm, fill: theme.primary, inset: (x: theme.margin, bottom: 12mm), align(left + bottom, if theme.logo-light != none { image(theme.logo-light, height: 9mm) }))
|
|
121
|
+
#block(width: 100%, inset: (x: theme.margin, top: 34mm), {
|
|
122
|
+
if kind != none {
|
|
123
|
+
text(size: 7.5pt, weight: 600, tracking: 0.04em, fill: theme.accent, upper(kind))
|
|
124
|
+
v(2pt)
|
|
125
|
+
}
|
|
126
|
+
par(leading: 0.4em, text(font: theme.font-heading, size: 28pt, weight: "bold", fill: theme.primary, breakable(title)))
|
|
127
|
+
v(10pt)
|
|
128
|
+
line(length: 36mm, stroke: 2pt + theme.accent)
|
|
129
|
+
})
|
|
130
|
+
#let meta = (
|
|
131
|
+
(t.doc-id, if doc-id != none { text(font: theme.font-mono, breakable(doc-id)) }),
|
|
132
|
+
(t.version, version),
|
|
133
|
+
(t.date, date),
|
|
134
|
+
(t.owner, owner),
|
|
135
|
+
(t.classification, if classification != none { text(weight: "bold", fill: theme.danger, classification) }),
|
|
136
|
+
).filter(m => m.at(1) != none)
|
|
137
|
+
#place(bottom + left, dx: theme.margin, dy: -20mm, grid(
|
|
138
|
+
columns: 2,
|
|
139
|
+
column-gutter: 14pt,
|
|
140
|
+
row-gutter: 7pt,
|
|
141
|
+
..meta.map(((k, v)) => (text(size: 8pt, fill: theme.muted, k), text(size: 9pt, v))).flatten(),
|
|
142
|
+
))
|
|
143
|
+
// the pages after the cover count from 1 (set inside the cover, so that nothing after it forces another page)
|
|
144
|
+
#counter(page).update(0)
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#let toc-page(theme: none, depth: 3) = {
|
|
149
|
+
let t = labels.at(theme.lang)
|
|
150
|
+
outline(title: t.contents, depth: depth)
|
|
151
|
+
pagebreak()
|
|
152
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archsight
|
|
4
|
+
module Pdf
|
|
5
|
+
# Building blocks for Typst source (https://typst.app). All text is emitted as Typst string literals
|
|
6
|
+
# (`#"..."`) and structure as function calls (`#strong[...]`), so no content can be mistaken for markup.
|
|
7
|
+
module Typst
|
|
8
|
+
LANGUAGE = /\A[A-Za-z0-9+#-]+\z/
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# A Typst string literal, for use as a function argument
|
|
13
|
+
def lit(text)
|
|
14
|
+
escaped = text.to_s.delete("\r").gsub(/[\\"]/) { "\\#{Regexp.last_match(0)}" }.gsub("\n", "\\n")
|
|
15
|
+
"\"#{escaped}\""
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A Typst string literal as markup, for use inside content
|
|
19
|
+
def str(text)
|
|
20
|
+
"##{lit(text)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A block of preformatted text
|
|
24
|
+
def raw(source, lang = nil)
|
|
25
|
+
lang_arg = lang.to_s.match?(LANGUAGE) ? ", lang: #{lit(lang)}" : ""
|
|
26
|
+
"#raw(#{lit(source.to_s.chomp)}, block: true#{lang_arg})"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A short text on a coloured background (a status)
|
|
30
|
+
def badge(text, hex)
|
|
31
|
+
"#box(fill: #{color(hex)}, inset: (x: 4pt, y: 1pt), radius: 2pt)[#{str(text)}]"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A framed message that stands where something could not be shown
|
|
35
|
+
def note(text, fill: "#fff3cc")
|
|
36
|
+
"#block(fill: #{color(fill)}, inset: 8pt, radius: 3pt, width: 100%)[#{str(text)}]"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A Typst colour from `#rrggbb` / `#rgb`
|
|
40
|
+
def color(hex)
|
|
41
|
+
"rgb(#{lit(hex)})"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "pdf/compiler"
|
|
6
|
+
require_relative "pdf/document"
|
|
7
|
+
|
|
8
|
+
module Archsight
|
|
9
|
+
# PDF export of pages and resources, laid out by Typst (https://typst.app, installed as the `typst` command).
|
|
10
|
+
# See Archsight::Pdf::Document for what steers it and Archsight::Resources::Config (type pdf) for the look.
|
|
11
|
+
module Pdf
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param resource [Archsight::Resources::Base] a page or any other resource
|
|
15
|
+
# @return [Archsight::Pdf::Document]
|
|
16
|
+
def document(database, resource, compiler: Compiler.new, **)
|
|
17
|
+
Document.new(database, resource, pdf_images: compiler.pdf_images?, **)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The PDF of a prepared document
|
|
21
|
+
# @return [String]
|
|
22
|
+
# @raise [Unavailable, Error]
|
|
23
|
+
def compile(document, compiler: Compiler.new)
|
|
24
|
+
Dir.mktmpdir("archsight-pdf") do |dir|
|
|
25
|
+
document.files.each { |name, content| File.binwrite(File.join(dir, name), content) }
|
|
26
|
+
compiler.compile(dir, font_paths: document.font_paths)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Config holds settings of a feature of the tool, kept with the model so they are shared and versioned like it
|
|
4
|
+
class Archsight::Resources::Config < Archsight::Resources::Base
|
|
5
|
+
include_annotations :architecture
|
|
6
|
+
|
|
7
|
+
description <<~MD
|
|
8
|
+
Holds the settings of a feature of the tool, such as the look of the PDF export.
|
|
9
|
+
|
|
10
|
+
## Definition
|
|
11
|
+
|
|
12
|
+
A Config is a tool-specific resource type. It carries no architecture, only settings, so that everything that
|
|
13
|
+
should look or behave the same is written once and shared through the resources repository instead of being
|
|
14
|
+
repeated in every page. It is not the per-user configuration file (tokens, URLs of other systems): that stays in
|
|
15
|
+
`~/.config/archsight/archsight.yaml`.
|
|
16
|
+
|
|
17
|
+
## Type
|
|
18
|
+
|
|
19
|
+
`config/type` says which feature the settings are for; the feature looks up its own Configs by type, so every
|
|
20
|
+
feature can have a Config named `default`. The settings of a feature use the annotations of its own prefix.
|
|
21
|
+
|
|
22
|
+
| `config/type` | Settings | Used by |
|
|
23
|
+
|---------------|----------|---------|
|
|
24
|
+
| `pdf` | `pdf/*`: colours, fonts, logo, header and footer | [PDF export](/doc/pdf-export) |
|
|
25
|
+
|
|
26
|
+
A document picks a different Config of the type with the tag `pdf:template:<name>`; without a tag the one named
|
|
27
|
+
`default` is used, and without any the export has a neutral built-in look.
|
|
28
|
+
|
|
29
|
+
## PDF settings
|
|
30
|
+
|
|
31
|
+
- **Colours** (`pdf/color-*`) are hex values (`#0b3d91`). `primary` is used for headings, the cover band and
|
|
32
|
+
table headers, `accent` for rules and links.
|
|
33
|
+
- **Fonts** (`pdf/font-*`) are family names. Fonts that are not installed fall back to the built-in ones; put
|
|
34
|
+
font files in a directory of the resources and name it in `pdf/font-dir` to ship them with the model.
|
|
35
|
+
- **Logos** (`pdf/logo`, `pdf/logo-light`) are image files (svg, png) of the resources directory, given
|
|
36
|
+
relative to it. `logo` goes on white (page header), `logo-light` on the coloured cover band.
|
|
37
|
+
- **Texts** (`pdf/footer-text`, `pdf/classification`) appear on every page.
|
|
38
|
+
MD
|
|
39
|
+
|
|
40
|
+
icon "settings"
|
|
41
|
+
layer "other"
|
|
42
|
+
|
|
43
|
+
TYPE_FORMAT = /\A[a-z][a-z0-9-]*\z/
|
|
44
|
+
|
|
45
|
+
annotation "config/type",
|
|
46
|
+
description: "The feature these settings are for (pdf). The feature looks up its Configs by this type",
|
|
47
|
+
title: "Type",
|
|
48
|
+
filter: :word,
|
|
49
|
+
summary: true,
|
|
50
|
+
validator: ->(value) { Archsight::Resources::Config.pattern_error(value, TYPE_FORMAT, "a lowercase word such as pdf") }
|
|
51
|
+
|
|
52
|
+
COLOR = /\A#(\h{3}|\h{6})\z/
|
|
53
|
+
FONT_NAME = /\A[\p{L}\p{N} ._+-]{1,80}\z/
|
|
54
|
+
LENGTH = /\A\d+(\.\d+)?(mm|cm|pt|in)\z/
|
|
55
|
+
|
|
56
|
+
# Colour annotations: key suffix, title, role
|
|
57
|
+
COLORS = {
|
|
58
|
+
"primary" => ["Primary colour", "headings, cover band, table headers"],
|
|
59
|
+
"accent" => ["Accent colour", "rules and links"],
|
|
60
|
+
"text" => ["Text colour", "body text"],
|
|
61
|
+
"muted" => ["Muted colour", "secondary text, header and footer"],
|
|
62
|
+
"ok" => ["Positive colour", "good status"],
|
|
63
|
+
"warn" => ["Warning colour", "warning status"],
|
|
64
|
+
"danger" => ["Danger colour", "bad status and the classification marking"]
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
COLORS.each do |key, (title, role)|
|
|
68
|
+
annotation "pdf/color-#{key}",
|
|
69
|
+
description: "#{title} as hex value (#rrggbb), used for #{role}",
|
|
70
|
+
title: title,
|
|
71
|
+
validator: ->(value) { Archsight::Resources::Config.pattern_error(value, COLOR, "a hex colour such as #0b3d91") },
|
|
72
|
+
sidebar: false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
%w[body heading mono].each do |role|
|
|
76
|
+
annotation "pdf/font-#{role}",
|
|
77
|
+
description: "Font family of the #{role} text",
|
|
78
|
+
title: "#{role.capitalize} font",
|
|
79
|
+
validator: ->(value) { Archsight::Resources::Config.pattern_error(value, FONT_NAME, "a font family name") },
|
|
80
|
+
sidebar: false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
annotation "pdf/font-dir",
|
|
84
|
+
description: "Directory with font files (ttf, otf), relative to the resources directory",
|
|
85
|
+
title: "Font directory",
|
|
86
|
+
sidebar: false
|
|
87
|
+
|
|
88
|
+
annotation "pdf/logo",
|
|
89
|
+
description: "Logo for the page header (svg or png), path relative to the resources directory",
|
|
90
|
+
title: "Logo",
|
|
91
|
+
validator: ->(value) { Archsight::Resources::Config.logo_error(value) },
|
|
92
|
+
sidebar: false
|
|
93
|
+
annotation "pdf/logo-light",
|
|
94
|
+
description: "Logo for the coloured cover band (svg or png), path relative to the resources directory",
|
|
95
|
+
title: "Logo (light)",
|
|
96
|
+
validator: ->(value) { Archsight::Resources::Config.logo_error(value) },
|
|
97
|
+
sidebar: false
|
|
98
|
+
|
|
99
|
+
annotation "pdf/footer-text",
|
|
100
|
+
description: "Text in the footer of every page (centre)",
|
|
101
|
+
title: "Footer text",
|
|
102
|
+
sidebar: false
|
|
103
|
+
annotation "pdf/classification",
|
|
104
|
+
description: "Marking shown in the footer of every page, e.g. Internal. A document can override it with " \
|
|
105
|
+
"the page property Classification",
|
|
106
|
+
title: "Classification",
|
|
107
|
+
sidebar: false
|
|
108
|
+
annotation "pdf/header-meta",
|
|
109
|
+
description: "Comma-separated parts of the header line: id, version, date, owner (default: all)",
|
|
110
|
+
title: "Header line",
|
|
111
|
+
sidebar: false,
|
|
112
|
+
validator: ->(value) { Archsight::Resources::Config.header_meta_error(value) }
|
|
113
|
+
annotation "pdf/lang",
|
|
114
|
+
description: "Language of the fixed texts (page, contents, ...)",
|
|
115
|
+
title: "Language",
|
|
116
|
+
enum: %w[en de],
|
|
117
|
+
sidebar: false
|
|
118
|
+
annotation "pdf/paper",
|
|
119
|
+
description: "Paper size",
|
|
120
|
+
title: "Paper",
|
|
121
|
+
enum: %w[a4 a5 us-letter],
|
|
122
|
+
sidebar: false
|
|
123
|
+
annotation "pdf/margin",
|
|
124
|
+
description: "Horizontal page margin as length (e.g. 16mm)",
|
|
125
|
+
title: "Margin",
|
|
126
|
+
validator: ->(value) { Archsight::Resources::Config.pattern_error(value, LENGTH, "a length such as 16mm") },
|
|
127
|
+
sidebar: false
|
|
128
|
+
|
|
129
|
+
HEADER_PARTS = %w[id version date owner].freeze
|
|
130
|
+
|
|
131
|
+
# Validator helpers: nil if the value is fine, else the message
|
|
132
|
+
def self.pattern_error(value, pattern, expected)
|
|
133
|
+
value.to_s.match?(pattern) ? nil : "expected #{expected}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.logo_error(value)
|
|
137
|
+
ext = File.extname(value.to_s).downcase
|
|
138
|
+
return nil if %w[.svg .png].include?(ext) && Archsight::Assets.normalize(value.to_s)
|
|
139
|
+
|
|
140
|
+
"expected a path of an svg or png file relative to the resources directory"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.header_meta_error(value)
|
|
144
|
+
unknown = value.to_s.split(",").map(&:strip) - HEADER_PARTS
|
|
145
|
+
unknown.empty? ? nil : "unknown part #{unknown.join(", ")}; use #{HEADER_PARTS.join(", ")}"
|
|
146
|
+
end
|
|
147
|
+
end
|
data/lib/archsight/version.rb
CHANGED