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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7ada570ca5a5647d0cb9dc9f23b37d8adc9c0b6eff5420154c8ef3d30d9ea95
|
|
4
|
+
data.tar.gz: ba2e28e035b6d091fbfaeef71b0bc8d775f84c88541ecea0de86ba7d256bde42
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
data/docs/pdf-export.md
ADDED
|
@@ -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** (``) 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
|
-
|
|
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] +
|
|
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| "[#
|
|
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},
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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)
|
data/lib/archsight/markdown.rb
CHANGED
|
@@ -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
|