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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +4 -0
  3. data/docs/configuration.md +5 -0
  4. data/docs/diagram.md +5 -0
  5. data/docs/index.md.erb +1 -0
  6. data/docs/pdf-export.md +118 -0
  7. data/lib/archsight/analysis/typst_renderer.rb +25 -78
  8. data/lib/archsight/cli.rb +37 -0
  9. data/lib/archsight/diagram/graph/attributes.rb +3 -1
  10. data/lib/archsight/diagram/graph/node.rb +18 -1
  11. data/lib/archsight/diagram/layout/expander.rb +75 -3
  12. data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
  13. data/lib/archsight/markdown.rb +17 -1
  14. data/lib/archsight/pdf/cache.rb +41 -0
  15. data/lib/archsight/pdf/compiler.rb +69 -0
  16. data/lib/archsight/pdf/dependency_graph.rb +127 -0
  17. data/lib/archsight/pdf/document.rb +227 -0
  18. data/lib/archsight/pdf/embeds.rb +134 -0
  19. data/lib/archsight/pdf/images.rb +94 -0
  20. data/lib/archsight/pdf/markdown.rb +217 -0
  21. data/lib/archsight/pdf/remote_image.rb +122 -0
  22. data/lib/archsight/pdf/resource_sheet.rb +129 -0
  23. data/lib/archsight/pdf/sample_document.rb +67 -0
  24. data/lib/archsight/pdf/tables.rb +72 -0
  25. data/lib/archsight/pdf/theme.rb +125 -0
  26. data/lib/archsight/pdf/typst/archsight.typ +152 -0
  27. data/lib/archsight/pdf/typst.rb +45 -0
  28. data/lib/archsight/pdf.rb +30 -0
  29. data/lib/archsight/resources/config.rb +147 -0
  30. data/lib/archsight/version.rb +1 -1
  31. data/lib/archsight/web/api/openapi/spec.yaml +230 -0
  32. data/lib/archsight/web/api/pdf_helpers.rb +46 -0
  33. data/lib/archsight/web/api/routes.rb +30 -0
  34. data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
  35. data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
  36. data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
  37. data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
  38. data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
  39. data/lib/archsight/web/public/vue/{InstanceRouter-CJSkGdBG.css → InstanceRouter-BCKbW-Wp.css} +1 -1
  40. data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
  41. data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
  42. data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
  43. data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
  44. data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
  45. data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
  46. data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
  47. data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
  48. data/lib/archsight/web/public/vue/{index-DMhBow5t.js → index-BF4zUlWZ.js} +2 -2
  49. data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
  50. data/lib/archsight/web/public/vue.html +2 -2
  51. metadata +35 -17
  52. data/lib/archsight/web/public/vue/DocPage-L0GzDFiV.js +0 -1
  53. data/lib/archsight/web/public/vue/EditorPage-B3iNbh1u.js +0 -35
  54. data/lib/archsight/web/public/vue/ErrorPage-Lro5vwhy.js +0 -2
  55. data/lib/archsight/web/public/vue/GraphView-BRKwxa--.js +0 -1
  56. data/lib/archsight/web/public/vue/HomePage-DKgI1DXr.js +0 -2
  57. data/lib/archsight/web/public/vue/InstanceRouter-CX6qxVTm.js +0 -1
  58. data/lib/archsight/web/public/vue/KindList-C556gf23.js +0 -1
  59. data/lib/archsight/web/public/vue/PageView-DAqHjL1x.js +0 -1
  60. data/lib/archsight/web/public/vue/QueryError-DvspMrsh.js +0 -1
  61. data/lib/archsight/web/public/vue/ResourceList-BZOYa68X.js +0 -2
  62. data/lib/archsight/web/public/vue/SearchResults-DaaOPG3t.js +0 -1
  63. data/lib/archsight/web/public/vue/WikiPage-Bfx92Iig.js +0 -3
  64. 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
@@ -4,5 +4,5 @@
4
4
  # Do not edit manually.
5
5
 
6
6
  module Archsight
7
- VERSION = "0.4.3"
7
+ VERSION = "0.5.0"
8
8
  end