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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66e3a2aa9223a9583edf06749e12bafe346bfd562570d299795f2334510ed36a
4
- data.tar.gz: 52a7997e394247a86da162fb4015d97b755b2ac2fe69cfadd758f6d3e140c33b
3
+ metadata.gz: f7ada570ca5a5647d0cb9dc9f23b37d8adc9c0b6eff5420154c8ef3d30d9ea95
4
+ data.tar.gz: ba2e28e035b6d091fbfaeef71b0bc8d775f84c88541ecea0de86ba7d256bde42
5
5
  SHA512:
6
- metadata.gz: adc6fb4fad180ae14920ecfbccebfb242c9b3d48bc6bfb196b645de67f4dc490e4922274175fa9f942cfe4d80a327b8a4cf171ad9ccc1edd195c5d283c636bba
7
- data.tar.gz: e82af637b926e46db4b61c23269d8f5b630222b78f6e169d413b21aa2f12858cbc80cacbc8325ab1d550174e11fedc8ca1780489d025c3dabe5c1943d1fd887b
6
+ metadata.gz: 739c46f505dfa73125839be21c2f0baaba7b4ba2a98926ca449a2cf87088cbc2c910c6b016c67ac7905540430927f94f2dc1b0bdce29735aec1ab29857390d7c
7
+ data.tar.gz: c259164f01f0d14e47dc5bf7f25a572a1a519dcc92ca788f3191274df5b6e0fd4d3d90fcdc5bc253bca6b454db464aa6ab46eaffa1f5a7f515b000a307506324
data/Dockerfile CHANGED
@@ -25,6 +25,10 @@ ENV GEM_HOME=/usr/local/bundle
25
25
  ENV GEM_PATH=/usr/local/bundle:/usr/local/lib/ruby/gems/4.0.0
26
26
  ENV PATH="/usr/local/bundle/bin:${PATH}"
27
27
 
28
+ # PDF export (Archsight::Pdf): typst typesets the documents, rsvg-convert turns the diagrams into vector graphics,
29
+ # graphviz draws the dependency graph of a resource
30
+ RUN apk add --no-cache typst rsvg-convert font-liberation font-dejavu font-noto-emoji graphviz
31
+
28
32
  RUN mkdir -p /resources
29
33
 
30
34
  ENV ARCHSIGHT_RESOURCES_DIR=/resources
@@ -37,6 +37,11 @@ that is set but empty counts as not set.
37
37
  | `jira.server` | `ARCHSIGHT_JIRA_SERVER` | Name of the Jira server as Confluence knows it (the `server` parameter of its Jira macro). Needed by the Confluence export when Confluence has more than one Jira server: without it the exported `{jira:KEY}` macros have no server and Confluence shows an error |
38
38
  | `jira.server_id` | `ARCHSIGHT_JIRA_SERVER_ID` | Id of that server (the `serverId` parameter). Copy both from the storage format of a Jira macro that already works in Confluence (page "Edit in storage format") |
39
39
  | `jira.token` | `ARCHSIGHT_JIRA_TOKEN` | Reserved, nothing uses it yet |
40
+ | `pdf.typst` | `ARCHSIGHT_PDF_TYPST` | Path of the `typst` command that typesets the [PDF export](/doc/pdf-export), if it is not on the `PATH` |
41
+ | `pdf.dot` | `ARCHSIGHT_PDF_DOT` | Path of Graphviz' `dot` command that draws the dependency graph in the [PDF export](/doc/pdf-export), if it is not on the `PATH`. Without it the PDF of a resource has no graph |
42
+ | `pdf.timeout` | `ARCHSIGHT_PDF_TIMEOUT` | Seconds a PDF may take to typeset before it is cancelled. Default `60` |
43
+ | `pdf.remote_images` | `ARCHSIGHT_PDF_REMOTE_IMAGES` | `false` stops the PDF export from fetching images that a page embeds by URL (default: fetched, public hosts only) |
44
+ | `pdf.max_analyses` | `ARCHSIGHT_PDF_MAX_ANALYSES` | How many `![[Analysis/...]]` embeds one PDF may run (each can take as long as its own timeout). Default `5`; further ones are shown as a note |
40
45
  | `import.cache_root` | `ARCHSIGHT_IMPORT_CACHE_ROOT` | Where the GitHub, GitLab and repository imports clone repositories, and what importers of the resources repository get as `cache_root` / `ARCHSIGHT_CACHE_DIR`. Default `~/.cache/archsight/git`; an import's own `import/config/cacheRoot` wins, see [Importers of the Resources Repository](/doc/import#importers-of-the-resources-repository). Every import makes sure the clones are not indexed by Spotlight (macOS): an empty `.metadata_never_index` in `~/.cache/archsight`, or in the cache root when it is elsewhere |
41
46
 
42
47
  ## Command line
data/docs/diagram.md CHANGED
@@ -145,6 +145,11 @@ hint for near misses), never silently ignored.
145
145
  | `link` | nodes, containers | `http(s)://…`, `mailto:…` or a relative URL |
146
146
  | `resource` | nodes, containers | a resource name (`Archsight:Web`) or `Kind/Name` (`ApplicationService/Archsight:Web`); see *Linking to resources* |
147
147
  | `extend` | nodes, containers | `true`, `false`; containers also `height` |
148
+ | `anchor` | containers | `top`, `bottom`, `left`, `right`, `center` |
149
+
150
+ `extend "height"` on a direct child of a `layer` grows it to its tallest sibling. Inside such a box (also through a `boundary` around a `stack`), a `stack` child carrying `extend "height"` takes the extra height and moves the ranks below it down, so e.g. a footer rank lines up across the columns. The child can be a `layer` too; its own `extend "height"` children then fill the layer's new height.
151
+
152
+ `anchor` says where a container's content sits when its box grows: `bottom` (or `top`) when it is made taller, `right` (or `left`) when it is made wider, `center` for the middle. Default: top for `extend "height"`, centered otherwise. It is not inherited, and has no effect on a box whose room is taken by a stack child with `extend "height"`.
148
153
  | `shape` | leaves | `rectangle`, `circle`, `cylinder`, `pipe`, `actor`, `file`, `module` |
149
154
  | `gap` | containers | pixels (`40`), a percentage (`150%`) or a signed one (`+20%`, `-50%`) |
150
155
  | `ranks` | containers | `auto`, `on`, `down`, `right`, `off` |
data/docs/index.md.erb CHANGED
@@ -8,6 +8,7 @@ Welcome to the Architecture Documentation System. This documentation covers how
8
8
  - [Configuration](/doc/configuration) - The configuration file and environment variables for tokens and URLs
9
9
  - [Query Syntax](/doc/search) - Search and filter resources
10
10
  - [Wiki Pages](/doc/pages) - Author markdown pages and arrange them in the sidebar tree
11
+ - [PDF Export](/doc/pdf-export) - Download pages and resources as PDF, with a Config for the look
11
12
  - [Architecture Modeling](/doc/modeling) - How to model your architecture
12
13
  - [REST API Documentation](/docs/api) - JSON API for programmatic access
13
14
  - [Diagrams](/doc/diagram) - Render `.asd` diagram files to SVG
@@ -0,0 +1,118 @@
1
+ # PDF Export
2
+
3
+ Every [page](/doc/pages) and every resource can be downloaded as a PDF. The document is typeset by [Typst](https://typst.app),
4
+ so the server (or the machine running the CLI) needs the `typst` command; the container image has it, together with
5
+ `rsvg-convert` (turns diagrams into vector graphics), `dot` of Graphviz (draws the dependency graph) and a few fonts.
6
+
7
+ ## Getting a PDF
8
+
9
+ - **Web UI:** the **PDF** button next to **Edit** on a page or resource.
10
+ - **API:** `GET /api/v1/pages/{name}/pdf` and `GET /api/v1/kinds/{kind}/instances/{name}/pdf`, see the [REST API documentation](/docs/api).
11
+ The response has an `ETag` that changes whenever the page, an image, a diagram or the template changes.
12
+ Query parameters: `template` (name of a pdf Config) and `draft=1` (watermark).
13
+ - **CLI:** `archsight pdf <page>` or `archsight pdf Kind/Name`, with `-o file.pdf`, `--template` and `--draft`.
14
+
15
+ Without `typst` the API answers `503` and the button shows the reason.
16
+
17
+ ## What is in the document
18
+
19
+ - A **page** is typeset as written: headings, text, tables (with their alignment), task lists, code, images next to the page,
20
+ `asd` diagrams (as vector graphics), `[[links]]` (as their label), `{status:...}` (a coloured box), `{jira:KEY}` (a link
21
+ when `jira.issue_url` is set) and `{emoticon:...}`. Macros that need the running application (`{children}`) are left out.
22
+ Mermaid blocks and draw.io images cannot be drawn on the server: a note stands there (and the Mermaid source).
23
+ - **Tables** have horizontal rules only: a stronger one under the header row and a light one under every other row, no frame
24
+ and no vertical lines, in the muted colour of the Config; the first column is flush with the text.
25
+ - **Live content is the data of the moment of the export**, as in the Confluence export:
26
+ - `![[View/Name]]` and ```` ```view ```` blocks are tables (title, number of items, repeated header row; long columns
27
+ share the width), `` ```requirements `` blocks are the requirements table.
28
+ - `![[Analysis/Name]]` runs the analysis and puts its sections (headings, text, tables, lists, messages) into the document.
29
+ An analysis runs for as long as its own `analysis/timeout` allows, so at most 5 run per document (`pdf.max_analyses`),
30
+ and a result is reused for a minute. A failed analysis shows its error.
31
+ - `![[Kind/Name]]` of another kind is the resource's description.
32
+ - Only an embed on a line of its own (or several, one per line) is content; inside a sentence it stays its name.
33
+ - What cannot be shown (unknown or ambiguous reference, an invalid view or requirements block) is a visible note in the
34
+ document, never blank space. Embeds inside an embedded description are not expanded again.
35
+ - **Images by URL** (`![](https://...)`) are fetched by the server so they can go into the PDF. Only public hosts on https/http
36
+ are contacted (never loopback, private or link-local addresses, so the server cannot be used to reach the network
37
+ behind it), at most 10 MB and 10 seconds each, and only real png, jpeg, gif or webp files are used; otherwise the alt text
38
+ is shown. `pdf.remote_images: false` turns this off.
39
+ - Any other **resource** becomes a generated document: its description, its properties, its relations and its
40
+ `architecture/diagram`, and its **dependency graph**, shown first, as on its page (the resources its relations lead to, two steps
41
+ deep, following its own layer and the one below it, nodes coloured by layer), drawn by `dot` in the direction, left to
42
+ right or top to bottom, in which it is larger on the page. A resource without
43
+ relations has no graph, and without `dot` the document is made without it. Properties are a label and value list (related annotations kept together; lists as "a, b";
44
+ multi-line and markdown values as sections of their own). Relations are shown in both directions, **Outgoing** (what this
45
+ resource points to) and **Incoming** (what points to it, read from the other side): the relation is named once, with the
46
+ kind next to it and the resources flowing in two columns, so a resource with many relations stays readable.
47
+
48
+ ## Steering the layout with tags
49
+
50
+ Tags of the page (`tags:` in the frontmatter) or of a resource (`architecture/tags`):
51
+
52
+ | Tag | Effect |
53
+ |-----|--------|
54
+ | `pdf:cover` | A cover page (logo band, title, id, version, date, owner) instead of the title block |
55
+ | `pdf:toc` | A table of contents |
56
+ | `pdf:landscape` | Landscape pages |
57
+ | `pdf:draft` | A "draft" watermark on every page |
58
+ | `pdf:no-graph` | Leave the dependency graph out of a resource's document |
59
+ | `pdf:template:<name>` | Use the pdf Config `<name>` instead of `default` |
60
+ | `version:<x>` | The document version in the header (else the page property `Version`, else `1`) |
61
+
62
+ Further page data used by the layout: `title`, `owner` (else `author`), `updated` (else today), and the page properties
63
+ `Document ID` (default `Kind/name`) and `Classification` (red marking in the footer, default from the template).
64
+
65
+ ## The look: Config
66
+
67
+ A `Config` resource (layer *Other*) holds settings of a feature of the tool, kept with the model; `config/type` says
68
+ which feature. The PDF export reads the Configs of type `pdf`: colours, fonts, logo and the text around the pages. The one
69
+ named `default` is used for every document that does not name another (`pdf:template:<name>`); `brand`, `PDF:brand` and
70
+ `Config:PDF:brand` all name the same Config. Without any a neutral look is used. A Config of another type is never used
71
+ for the PDF, so every feature can have its own `default`. (The per-user file with tokens and URLs stays
72
+ [`~/.config/archsight/archsight.yaml`](/doc/configuration).)
73
+
74
+ ```yaml
75
+ apiVersion: architecture/v1alpha1
76
+ kind: Config
77
+ metadata:
78
+ name: Config:PDF:default
79
+ annotations:
80
+ config/type: pdf
81
+ pdf/color-primary: "#1f3a5f"
82
+ pdf/color-accent: "#2f7fd1"
83
+ pdf/logo: assets/logo.svg
84
+ pdf/logo-light: assets/logo-white.svg
85
+ pdf/footer-text: Generated with Archsight
86
+ pdf/classification: Internal
87
+ pdf/font-body: Open Sans
88
+ pdf/font-dir: assets/fonts
89
+ spec: {}
90
+ ```
91
+
92
+ | Annotation | Meaning |
93
+ |------------|---------|
94
+ | `config/type` | `pdf` for these settings |
95
+ | `pdf/color-primary`, `-accent`, `-text`, `-muted`, `-ok`, `-warn`, `-danger` | Hex colours (`#rrggbb`) |
96
+ | `pdf/font-body`, `-heading`, `-mono` | Font families, tried before the built-in fallbacks |
97
+ | `pdf/font-dir` | Directory (relative to the resources directory) with font files (ttf, otf) that ship with the model |
98
+ | `pdf/logo`, `pdf/logo-light` | svg or png, relative to the resources directory. `logo` is shown on white (page header), `logo-light` on the cover band |
99
+ | `pdf/footer-text`, `pdf/classification` | Texts in the footer of every page |
100
+ | `pdf/header-meta` | Parts of the header line: `id, version, date, owner` |
101
+ | `pdf/lang` | Language of the fixed texts: `en` or `de` |
102
+ | `pdf/paper`, `pdf/margin` | `a4`, `a5` or `us-letter`; horizontal margin such as `16mm` |
103
+
104
+ Values are checked when they are loaded (and by `archsight lint`): something that is not a colour, a font name or a length is
105
+ ignored and the default is used.
106
+
107
+ ## Seeing the look
108
+
109
+ The page of a Config of type `pdf` shows its settings as they look (the colours, the fonts, the logo on white and on
110
+ the cover band, the page settings) and, beside them, a **preview**: a fixed sample document (a cover page and a page with
111
+ headings, a list, a quote, a table with status badges and code) typeset by the server with exactly these settings, one
112
+ tab for the cover and one for the page. It shows the saved Config; after an edit the page typesets it again.
113
+ The same sample is `GET /api/v1/kinds/Config/instances/{name}/preview` (`?part=cover` or `?part=page` for one page).
114
+
115
+ ## Settings
116
+
117
+ `pdf.typst` (`ARCHSIGHT_PDF_TYPST`) is the path of the `typst` command if it is not on the `PATH`, `pdf.dot` (`ARCHSIGHT_PDF_DOT`) the same for `dot`, `pdf.timeout`
118
+ (`ARCHSIGHT_PDF_TIMEOUT`) the seconds a document may take (default 60), `pdf.remote_images` (`ARCHSIGHT_PDF_REMOTE_IMAGES`) `false` to never fetch images by URL, `pdf.max_analyses` (`ARCHSIGHT_PDF_MAX_ANALYSES`) the number of analyses one document may run (default 5). See [Configuration](/doc/configuration).
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../markdown"
4
+ require_relative "../pdf/typst"
5
+ require_relative "../pdf/markdown"
4
6
  require_relative "../diagram"
5
7
 
6
8
  module Archsight
@@ -10,20 +12,18 @@ module Archsight
10
12
  # All text is emitted as Typst string literals (`#"..."`) and structure as
11
13
  # function calls (`#strong[...]`), so no content can be mistaken for markup.
12
14
  class TypstRenderer
15
+ include Archsight::Pdf::Typst
16
+
13
17
  ROW_LIMIT = 10
14
18
  HEADING_OFFSET = 2 # section level 0 becomes a level-2 heading, the analysis name is level 1
15
- LANGUAGE = /\A[A-Za-z0-9+#-]+\z/
16
19
  MESSAGE_FILL = { error: "rgb(\"#fde8e8\")", warning: "rgb(\"#fff3cc\")", info: "rgb(\"#ddeeff\")" }.freeze
17
- SYMBOLS = {
18
- ndash: "–", mdash: "—", hellip: "…", laquo: "«", raquo: "»", laquo_space: "« ", raquo_space: " »",
19
- lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”"
20
- }.freeze
21
20
 
22
21
  PREAMBLE = <<~TYPST
23
22
  #set document(title: "Archsight analysis")
24
23
  #set page(paper: "a4", margin: 2cm, numbering: "1")
25
24
  #set text(size: 10pt)
26
25
  #show raw.where(block: true): it => block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%, it)
26
+ #set table(stroke: (x, y) => if y == 0 { (bottom: 0.7pt + luma(150)) } else { (bottom: 0.4pt + luma(220)) }, inset: (x, y) => (left: if x == 0 { 0pt } else { 6pt }, right: 6pt, y: 6pt))
27
27
  TYPST
28
28
 
29
29
  # @param results [Array<Archsight::Analysis::Result>]
@@ -31,10 +31,12 @@ module Archsight
31
31
  # @param template [String, nil] Typst file to import instead of the built-in layout. It must define
32
32
  # `archsight-report` (a show rule for the document) and `analysis-result` (one call per analysis,
33
33
  # with the data sections as a dictionary and the other sections as body).
34
- def initialize(results, verbose: false, template: nil)
34
+ # @param full [Boolean] show every table row and list item, without the error backtrace of `verbose`
35
+ def initialize(results, verbose: false, template: nil, full: false)
35
36
  @results = results
36
37
  @verbose = verbose
37
38
  @template = template
39
+ @full = full
38
40
  end
39
41
 
40
42
  # @return [String] Typst source
@@ -44,6 +46,17 @@ module Archsight
44
46
  [PREAMBLE, title, summary, *@results.map { |result| render_result(result) }].join("\n")
45
47
  end
46
48
 
49
+ # One section of a result as Typst markup (nil for data that only templates use)
50
+ # @param heading_offset [Integer] added to the level of a heading section
51
+ def section(section, heading_offset: HEADING_OFFSET)
52
+ render_section(section, heading_offset)
53
+ end
54
+
55
+ # The error of a failed result as Typst markup
56
+ def error(result)
57
+ error_block(result)
58
+ end
59
+
47
60
  private
48
61
 
49
62
  def title
@@ -120,9 +133,9 @@ module Archsight
120
133
  lines.join("\n\n")
121
134
  end
122
135
 
123
- def render_section(section)
136
+ def render_section(section, heading_offset = HEADING_OFFSET)
124
137
  case section[:type]
125
- when :heading then "#heading(level: #{[section[:level] + HEADING_OFFSET, 6].min})[#{str(section[:text])}]"
138
+ when :heading then "#heading(level: #{[section[:level] + heading_offset, 6].min})[#{str(section[:text])}]"
126
139
  when :text then markdown(section[:content])
127
140
  when :table then table(section[:headers], section[:rows])
128
141
  when :list then list(section[:items])
@@ -139,15 +152,14 @@ module Archsight
139
152
 
140
153
  # A template produces a document to hand out, so it gets every row
141
154
  def full?
142
- @verbose || !@template.nil?
155
+ @verbose || @full || !@template.nil?
143
156
  end
144
157
 
145
158
  def table(headers, rows)
146
159
  shown = full? ? rows : rows.first(ROW_LIMIT)
147
- head = headers.map { |header| "[#strong[#{str(header)}]]" }
160
+ head = headers.map { |header| "[#text(weight: \"semibold\")[#{str(header)}]]" }
148
161
  cells = shown.flat_map { |row| headers.each_index.map { |i| "[#{str(row[i])}]" } }
149
- out = "#table(columns: #{headers.size}, inset: 5pt, stroke: 0.5pt + luma(200), " \
150
- "table.header(#{head.join(", ")}), #{cells.join(", ")})"
162
+ out = "#table(columns: #{headers.size}, table.header(#{head.join(", ")}), #{cells.join(", ")})"
151
163
  out += "\n\n#emph[#{str("...and #{rows.size - ROW_LIMIT} more rows")}]" if shown.size < rows.size
152
164
  out
153
165
  end
@@ -172,73 +184,8 @@ module Archsight
172
184
  [message_block(:error, "Diagram could not be rendered: #{e.message}"), raw(source, "asd")].join("\n\n")
173
185
  end
174
186
 
175
- def raw(source, lang)
176
- lang_arg = lang.to_s.match?(LANGUAGE) ? ", lang: #{lit(lang)}" : ""
177
- "#raw(#{lit(source.to_s.chomp)}, block: true#{lang_arg})"
178
- end
179
-
180
- # A Typst string literal, for use as a function argument
181
- def lit(text)
182
- escaped = text.to_s.delete("\r").gsub(/[\\"]/) { "\\#{Regexp.last_match(0)}" }.gsub("\n", "\\n")
183
- "\"#{escaped}\""
184
- end
185
-
186
- # A Typst string literal as markup, for use inside content
187
- def str(text)
188
- "##{lit(text)}"
189
- end
190
-
191
- # ---- markdown -> Typst
192
-
193
187
  def markdown(source, inline: false)
194
- root = Archsight::Markdown.document(source).root
195
- out = blocks(root.children)
196
- inline ? out.gsub(/\n\n+/, " #linebreak() ") : out
197
- end
198
-
199
- def blocks(elements)
200
- elements.filter_map { |node| block(node) }.join("\n\n")
201
- end
202
-
203
- def block(node)
204
- case node.type
205
- when :p then inlines(node.children)
206
- when :header then "#heading(level: #{[node.options[:level] + 1, 6].min})[#{inlines(node.children)}]"
207
- when :codeblock then raw(node.value, node.options[:lang])
208
- when :blockquote then "#quote(block: true)[#{blocks(node.children)}]"
209
- when :ul then "#list(#{node.children.map { |li| "[#{blocks(li.children)}]" }.join(", ")})"
210
- when :ol then "#enum(#{node.children.map { |li| "[#{blocks(li.children)}]" }.join(", ")})"
211
- when :hr then "#line(length: 100%)"
212
- when :table then markdown_table(node)
213
- when :blank, :html_element, :xml_comment then nil
214
- else inlines(node.children) unless node.children.empty?
215
- end
216
- end
217
-
218
- def markdown_table(node)
219
- rows = node.children.flat_map(&:children)
220
- cells = rows.map { |tr| tr.children.map { |td| "[#{inlines(td.children)}]" } }
221
- width = cells.map(&:size).max || 1
222
- "#table(columns: #{width}, inset: 5pt, stroke: 0.5pt + luma(200), #{cells.flatten.join(", ")})"
223
- end
224
-
225
- def inlines(elements)
226
- elements.map { |node| inline(node) }.join
227
- end
228
-
229
- def inline(node)
230
- case node.type
231
- when :text then str(node.value)
232
- when :em then "#emph[#{inlines(node.children)}]"
233
- when :strong then "#strong[#{inlines(node.children)}]"
234
- when :codespan then "#raw(#{lit(node.value)})"
235
- when :a then "#link(#{lit(node.attr["href"])})[#{inlines(node.children)}]"
236
- when :br then "#linebreak()"
237
- when :entity then str(node.value.char)
238
- when :smart_quote, :typographic_sym then str(SYMBOLS.fetch(node.value, node.value.to_s))
239
- when :html_element then inlines(node.children)
240
- else inlines(node.children)
241
- end
188
+ Archsight::Pdf::Markdown.new(heading_offset: 1, diagram: method(:diagram)).render(source, inline: inline)
242
189
  end
243
190
  end
244
191
  end
data/lib/archsight/cli.rb CHANGED
@@ -356,6 +356,43 @@ module Archsight
356
356
  exit 1
357
357
  end
358
358
 
359
+ desc "pdf TARGET", "Export a page or resource as PDF (needs typst)"
360
+ long_desc <<~DESC
361
+ Typesets a page (TARGET is its name) or any other resource (TARGET is Kind/Name) as a PDF with Typst, the same
362
+ document the PDF button of the web UI downloads. The look comes from the Config of type pdf named default (or --template),
363
+ the tags pdf:cover, pdf:toc, pdf:landscape, pdf:draft and pdf:template:<name> steer the layout. The typst command
364
+ is taken from the PATH, or from ARCHSIGHT_PDF_TYPST / `pdf.typst` in ~/.config/archsight/archsight.yaml.
365
+ See the PDF Export page of the documentation.
366
+ DESC
367
+ option :output, aliases: "-o", type: :string, desc: "Output file (default: <name>.pdf in the current directory)"
368
+ option :template, type: :string, desc: "Name of the pdf Config to use (default: the tag or `default`)"
369
+ option :draft, type: :boolean, default: false, desc: "Add the draft watermark"
370
+ def pdf(target)
371
+ configure_resources
372
+ require "archsight/database"
373
+ require "archsight/pdf"
374
+
375
+ db = Archsight::Database.new(Archsight.resources_dir, compute_annotations: false)
376
+ begin
377
+ db.reload!
378
+ rescue Archsight::ResourceError => e
379
+ display_error_with_context(e.to_s)
380
+ exit 1
381
+ end
382
+
383
+ kind, name = target.include?("/") ? target.split("/", 2) : ["Page", target]
384
+ resource = Archsight::Resources[kind] && (db.instance_by_kind(kind, name) || db.instance_by_alias(kind, name))
385
+ abort "Error: #{kind} '#{name}' not found" unless resource
386
+
387
+ document = Archsight::Pdf.document(db, resource, template: options[:template], draft: options[:draft])
388
+ file = options[:output] || document.filename
389
+ File.binwrite(file, Archsight::Pdf.compile(document))
390
+ puts "Wrote #{file}"
391
+ rescue Archsight::Pdf::Error => e
392
+ warn "Error: #{e.message}"
393
+ exit 1
394
+ end
395
+
359
396
  desc "module SUBCOMMAND", "Module analysis commands (e.g. module graph PATH)"
360
397
  subcommand "module", ModuleCLI
361
398
 
@@ -27,13 +27,14 @@ module Archsight
27
27
  NODE = %w[label tint link resource extend].freeze
28
28
  LEAF_KINDS = %i[component application api database queue actor file].freeze
29
29
  LEAF = (NODE + %w[shape]).freeze
30
- CONTAINER = (NODE + %w[gap ranks columns]).freeze
30
+ CONTAINER = (NODE + %w[gap ranks columns anchor]).freeze
31
31
  EDGE = %w[label style relation tint].freeze
32
32
  DATAFLOW = %w[label color].freeze
33
33
 
34
34
  EDGE_STYLES = %w[straight orthogonal].freeze
35
35
  LEAF_EXTEND = %w[true false].freeze
36
36
  CONTAINER_EXTEND = %w[true false height].freeze
37
+ ANCHORS = %w[top bottom left right center].freeze
37
38
 
38
39
  # `40` (pixels), `150%` (of the default), `+20%`/`-50%` (relative);
39
40
  # see `GapSpec`.
@@ -116,6 +117,7 @@ module Archsight
116
117
  when "tint" then check_value!(key, value, Tints.names, where, line)
117
118
  when "ranks" then check_value!(key, value, ranks_modes, where, line)
118
119
  when "extend" then check_value!(key, value, leaf?(block) ? LEAF_EXTEND : CONTAINER_EXTEND, where, line)
120
+ when "anchor" then check_value!(key, value, ANCHORS, where, line)
119
121
  when "gap" then check_format!(key, value, GAP_FORMAT, "a pixel count (40), a percentage (150%) or a signed one (+20%, -50%)", where, line)
120
122
  when "link" then check_link!(value, where, line)
121
123
  when "resource" then check_resource!(value, block, where, line)
@@ -130,11 +130,28 @@ module Archsight
130
130
  # `extend "height"` on a direct child of a `layer` grows that
131
131
  # child's box to its tallest sibling's height, leaving its own
132
132
  # content top-aligned where it already sits. Never inherited --
133
- # only the node carrying the attr is affected.
133
+ # only the node carrying the attr is affected. In a `stack` inside such a
134
+ # grown box it absorbs the extra height instead (see
135
+ # `Expander#grow_height`).
134
136
  def extend_height?
135
137
  attrs["extend"] == "height"
136
138
  end
137
139
 
140
+ # Where this container's content sits when its box grows by `extra`
141
+ # along `axis`: the `anchor` attr (`top`/`bottom` for height,
142
+ # `left`/`right` for width, `center`), else `default`. Returns how
143
+ # far to shift the children (0 = leading edge, `extra` = trailing).
144
+ # Never inherited; a value for the other axis is ignored.
145
+ def anchor_shift(axis, extra, default:)
146
+ value = attrs["anchor"]
147
+ value = default.to_s unless (axis == Axis::HEIGHT ? %w[top bottom center] : %w[left right center]).include?(value)
148
+ case value
149
+ when "top", "left" then 0.0
150
+ when "bottom", "right" then extra.to_f
151
+ else extra / 2.0
152
+ end
153
+ end
154
+
138
155
  def link
139
156
  attrs["link"]
140
157
  end
@@ -97,29 +97,101 @@ module Archsight
97
97
  # their own (now wider) box -- explaining a rank that visibly
98
98
  # spills outside its container despite matching its siblings'
99
99
  # width exactly.
100
- center = own_target / 2.0
100
+ #
101
+ # The room they get is the *inner* one -- the target minus the
102
+ # node's own padding (and title bar), as in the single-child
103
+ # case below -- or they would sit flush on its border; the
104
+ # centerline is the middle of that inner area.
105
+ inner = own_target - inset(node, axis)
106
+ center = leading_inset(node, axis) + (inner / 2.0)
101
107
  node.children.each do |c|
102
- expand(c, axis, own_target, inherited: extend)
108
+ expand(c, axis, inner, inherited: extend)
103
109
  axis.set_position(@boxes[c.id], center)
104
110
  end
105
111
  end
106
112
  return
107
113
  end
108
114
 
109
- recenter_children(node, axis, extra / 2.0) if extra.positive?
115
+ recenter_children(node, axis, node.anchor_shift(axis, extra, default: :center)) if extra.positive?
110
116
 
111
117
  return unless node.children.length == 1
112
118
 
113
119
  expand(node.children.first, axis, own_target - inset(node, axis), inherited: extend)
114
120
  end
115
121
 
122
+ # `extend "height"`: grows `node` to `target` height keeping its
123
+ # content top-aligned (or at its `anchor`), then hands the extra room to whatever inside
124
+ # can absorb it -- a `stack` child carrying `extend "height"`
125
+ # itself (the first such one in a stack's column gets all of it, and
126
+ # the ranks below it move down, so e.g. a footer rank lines up
127
+ # across columns), reached through single-child wrappers such as a
128
+ # `boundary` around a `stack`.
129
+ def grow_height(node, target)
130
+ box = @boxes[node.id]
131
+ extra = target - box.height
132
+ return if extra <= 0
133
+
134
+ box.height = target
135
+ return if node.children.empty?
136
+
137
+ # An explicit `anchor` decides where the content sits; otherwise a
138
+ # stack (or a wrapper around one) hands the room to its
139
+ # `extend "height"` child, and anything else stays top-aligned.
140
+ delta = node.anchor_shift(Axis::HEIGHT, extra, default: :top)
141
+ return recenter_children(node, Axis::HEIGHT, delta) if delta.positive?
142
+
143
+ if node.layer?
144
+ fill_layer(node, extra)
145
+ elsif node.children.length == 1
146
+ inner = node.children.first
147
+ grow_top_aligned(inner, extra) if inner.stack? || inner.extend_height?
148
+ elsif node.stack?
149
+ absorb_in_stack(node, extra)
150
+ end
151
+ end
152
+
116
153
  private
117
154
 
155
+ # A grown `layer`'s `extend "height"` children fill the new height
156
+ # too (they were only matched to their tallest sibling before).
157
+ def fill_layer(node, extra)
158
+ room = @boxes[node.id].height - inset(node, Axis::HEIGHT)
159
+ node.children.select(&:extend_height?).each do |c|
160
+ grow = room - @boxes[c.id].height
161
+ grow_top_aligned(c, grow) if grow.positive? && extra.positive?
162
+ end
163
+ end
164
+
165
+ # Grows a child by `extra` keeping its top edge where it is (box
166
+ # positions are centers, so the center moves down by half of it).
167
+ def grow_top_aligned(node, extra)
168
+ grow_height(node, @boxes[node.id].height + extra)
169
+ @boxes[node.id].y += extra / 2.0
170
+ end
171
+
172
+ # Gives `extra` to the first child of the `stack` `node` marked
173
+ # `extend "height"`, and shifts every later rank down by it.
174
+ def absorb_in_stack(node, extra)
175
+ ordered = node.children.sort_by { |c| @boxes[c.id].y }
176
+ index = ordered.index(&:extend_height?)
177
+ return unless index
178
+
179
+ grower = ordered[index]
180
+ grow_top_aligned(grower, extra)
181
+ ordered[(index + 1)..].each { |c| @boxes[c.id].y += extra }
182
+ end
183
+
118
184
  # How much of a container's size on `axis` is reserved by its own
119
185
  # padding (and, on the height axis, its title bar) rather than
120
186
  # available to a single child filling it -- the node's own
121
187
  # `layout_padding`/`layout_title_height` (nothing, for an anonymous
122
188
  # `layer`/`stack`, since it renders no box to pad).
189
+ # The padding (plus title bar, on the height axis) before the content.
190
+ def leading_inset(node, axis)
191
+ padding = node.layout_padding(@theme)
192
+ axis == Axis::HEIGHT ? padding + node.layout_title_height(@theme) : padding
193
+ end
194
+
123
195
  def inset(node, axis)
124
196
  padding = 2 * node.layout_padding(@theme)
125
197
  axis == Axis::HEIGHT ? padding + node.layout_title_height(@theme) : padding
@@ -74,7 +74,9 @@ module Archsight
74
74
  def arrange_layer(group, children)
75
75
  max_height = children.map { |c| @boxes[c.id].height }.max
76
76
  children.each do |c|
77
- @boxes[c.id].height = max_height if !c.leaf? && c.extend_height?
77
+ next if c.leaf? || !c.extend_height?
78
+
79
+ Expander.new(@boxes, theme: @theme).grow_height(c, max_height)
78
80
  end
79
81
 
80
82
  gap = group.gap(@theme.sibling_gap)
@@ -14,9 +14,25 @@ module Archsight
14
14
  # Raw HTML whose text is kept as it is
15
15
  VERBATIM = %w[pre code script style textarea].freeze
16
16
 
17
+ # Fenced code and inline code, which are left as written
18
+ CODE = /(^[ \t]*(?:`{3,}|~{3,}).*?^[ \t]*(?:`{3,}|~{3,})[ \t]*$|`+[^`\n].*?`+)/m
19
+ WIKI_LINK_PIPE = /\[\[[^\]\n]*\]\]/
20
+
17
21
  def self.document(text, **)
18
- Kramdown::Document.new(text.to_s, **OPTIONS, **)
22
+ Kramdown::Document.new(protect_wiki_link_pipes(text.to_s), **OPTIONS, **)
23
+ end
24
+
25
+ # Kramdown reads every line with a `|` as a table row, which would cut `[[Target|label]]` in two (a list item
26
+ # "Related: [[A/B|b]], [[C/D|d]]" became one table row). An escaped pipe is a plain `|` again in the HTML,
27
+ # which is what WikiLinks expects; code is untouched, it would show the backslash.
28
+ def self.protect_wiki_link_pipes(text)
29
+ return text unless text.include?("[[") && text.include?("|")
30
+
31
+ text.split(CODE).each_with_index.map do |part, index|
32
+ index.odd? ? part : part.gsub(WIKI_LINK_PIPE) { |link| link.gsub(/(?<!\\)\|/, "\\\\|") }
33
+ end.join
19
34
  end
35
+ private_class_method :protect_wiki_link_pipes
20
36
 
21
37
  # The document without the newlines of the source in its text, for a target that keeps a newline instead of
22
38
  # reading it as a space (Confluence): a soft line break becomes a space, the newline after a line break goes.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Pdf
5
+ # The last PDFs that were made, by fingerprint of what they were made from, and a limit on how many are made at
6
+ # the same time (typst needs a CPU for seconds, a page nobody changed needs none).
7
+ class Cache
8
+ def initialize(entries: 16, parallel: 2)
9
+ @entries = entries
10
+ @store = {}
11
+ @lock = Mutex.new
12
+ @slots = SizedQueue.new(parallel)
13
+ parallel.times { @slots << true }
14
+ end
15
+
16
+ # @param key [String] fingerprint
17
+ # @yield makes the PDF when it is not cached
18
+ # @return [String]
19
+ def fetch(key, &)
20
+ cached = @lock.synchronize { @store.delete(key)&.tap { |pdf| @store[key] = pdf } }
21
+ return cached if cached
22
+
23
+ pdf = make(&)
24
+ @lock.synchronize do
25
+ @store[key] = pdf
26
+ @store.shift while @store.size > @entries
27
+ end
28
+ pdf
29
+ end
30
+
31
+ private
32
+
33
+ def make
34
+ @slots.pop
35
+ yield
36
+ ensure
37
+ @slots << true
38
+ end
39
+ end
40
+ end
41
+ end