stationery 0.4.0 → 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/CHANGELOG.md +14 -0
- data/README.md +40 -12
- data/lib/stationery/builder.rb +12 -0
- data/lib/stationery/canvas/debug.rb +1 -1
- data/lib/stationery/canvas.rb +26 -0
- data/lib/stationery/elements/rich.rb +7 -5
- data/lib/stationery/elements.rb +25 -1
- data/lib/stationery/fonts/fallback.rb +6 -4
- data/lib/stationery/fonts/font.rb +51 -8
- data/lib/stationery/layout/box.rb +59 -13
- data/lib/stationery/layout/image.rb +33 -4
- data/lib/stationery/layout/stack.rb +81 -0
- data/lib/stationery/minitest.rb +1 -0
- data/lib/stationery/preview.rb +23 -2
- data/lib/stationery/rails/previews_controller.rb +2 -7
- data/lib/stationery/rich/renderer/inlines.rb +10 -2
- data/lib/stationery/rich/renderer/links.rb +28 -0
- data/lib/stationery/rich/renderer.rb +6 -3
- data/lib/stationery/rich/styles.rb +2 -0
- data/lib/stationery/rspec.rb +4 -0
- data/lib/stationery/testing/inspector.rb +8 -2
- data/lib/stationery/testing/matchers.rb +10 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dab8be06e23e27d74ddd2ec627bc34923a79a9c0cb5fe40da55f43bdcb81e337
|
|
4
|
+
data.tar.gz: a7f90eadef2004318933e8a65cd1f86841c5b9bfc4cba8ff6191b2f96946b8e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ce40fc76233a4c6ba0ca455c9c80ac3a651832ba86ab7003c8f907b2abd8291cbe0a7421302b4a6762e876df284ebe98ed862017eaed7cb4854d609e3fa557ec
|
|
7
|
+
data.tar.gz: b1c1928f47210a8b29586f73881b8430978c644a2ea7489fab251796b7192213057a414c3145c47502fa267e3c857d5b581f91d8bf2bca394e61384844fc21d8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
Rotated, overlapping, cropped photos (the collage look), and fixes from the first weeks of use.
|
|
6
|
+
|
|
7
|
+
- `stack { … }` and `layer(top:, right:, bottom:, left:, width:, height:, **box) { … }`: overlapping content. The stack's ordinary children set its height; each layer is a box placed with CSS-inset semantics relative to the stack (points, or fractions of the stack's width/height; negative insets overhang) and takes no flow height. A stack moves to the next page whole. `examples/postcard.rb` shows the collage: a cover photo with two tilted, framed, shadowed photos over its corners.
|
|
8
|
+
- `box(rotate:)` turns a box clockwise around its centre without moving its layout rectangle (a rotated box never splits); `box(shadow: true | { offset:, blur:, color:, opacity: })` paints a soft drop shadow as stacked rounded rectangles at fading opacity, an artifact taking no space; `box(overflow: :hidden)` clips the content to the rounded outline. `column` takes the same options.
|
|
9
|
+
- `image(fit: :cover, width:, height:)` scales up to fill the box and clips the excess around the centre; `image(radius:)` clips to rounded corners; `image(rotate:)` turns the painted image around its centre. Sizing, measuring and the tagged `Figure` bbox are unchanged; plain images are byte-for-byte as before.
|
|
10
|
+
- `Canvas#rotate(degrees, around: [x, y]) { … }` and `Canvas#transform([a, b, c, d, e, f]) { … }` wrap a block in a `cm` transform given in top-left space, clockwise like CSS `rotate()`. Link and form-widget rectangles are annotations in page space, so they stay unrotated.
|
|
11
|
+
- `html`/`markdown` write link annotations only for `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`); anything else (`javascript:`, `data:`, a relative path) keeps its text without a link and is reported as a `Warnings::DroppedLink`. `links: %w[http https]` changes the list, `links: :all` keeps every href from a trusted source.
|
|
12
|
+
- `html`/`markdown` `styles:` take `ul:` and `ol:` (`gap:`, `indent:`, `marker_gap:`, `marker_color:`, plus `style:` for `ul` and `format:`/`suffix:` for `ol`), passed straight to the list elements, so dense documents can tighten the 4pt item gap.
|
|
13
|
+
- Whitespace no font has (an ideographic space U+3000, a figure space U+2007, a narrow no-break space U+202F, thin and hair spaces, …) is drawn as a blank of the character's conventional width instead of `.notdef`, and no longer reports `MissingGlyph`; font fallback keeps every Unicode White_Space character with its neighbours.
|
|
14
|
+
- Testing: the RSpec matchers include `RSpec::Matchers::Composable`, so `have_pdf_text(…).and have_bookmark(…)`, `.or` and use inside `all`/`include` work. `Inspector#lang` reads the catalog `/Lang`; `have_pdf_language("de")` and `assert_pdf_language` assert it.
|
|
15
|
+
- Previews: `Stationery::Preview#around_render(name, params)` wraps both the preview method and `to_pdf`, for an I18n locale or `CurrentAttributes` that must be in effect while the document renders (`?locale=de`); `Preview#to_pdf(name, params, debug:)` is the entry point the previews controller uses.
|
|
16
|
+
|
|
3
17
|
## 0.4.0 (2026-09-27)
|
|
4
18
|
|
|
5
19
|
Interactive forms and tagged (accessible) PDF.
|
data/README.md
CHANGED
|
@@ -70,7 +70,7 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
|
|
|
70
70
|
InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
`examples/` has a complete, runnable invoice, annual report, letter, packing slip and
|
|
73
|
+
`examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form and postcard collage; `bundle exec rake examples`
|
|
74
74
|
renders them all, or render one with `stationery render examples/report.rb`.
|
|
75
75
|
|
|
76
76
|
## Elements
|
|
@@ -80,12 +80,14 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
80
80
|
| `text(string, **style)` | A paragraph. Plain strings are literal. |
|
|
81
81
|
| `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric and HTML 4 named entities. |
|
|
82
82
|
| `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
|
|
83
|
-
| `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). |
|
|
83
|
+
| `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. |
|
|
84
84
|
| `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
|
|
85
85
|
| `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. |
|
|
86
|
-
| `image(path_or_io, width:, height:, fit:, align:)` | JPEG or PNG, aspect preserved. |
|
|
86
|
+
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:)` | JPEG or PNG, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. |
|
|
87
87
|
| `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors). |
|
|
88
88
|
| `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
|
|
89
|
+
| `stack(gap:, align:) { }` | A base with layers painted over it: ordinary children set the height, `layer` children float over them and take no space. Moves to the next page whole. |
|
|
90
|
+
| `layer(top:, right:, bottom:, left:, width:, height:, **box) { }` | Inside `stack`: a box placed by insets from the stack's edges, in points or as a fraction (`0.4`, `1/3r`) of its width/height; negative insets overhang. Takes every box option (`rotate:`, `shadow:`, `radius:`, …). |
|
|
89
91
|
| `ul(style:, gap:, indent:, marker_gap:, marker_color:) { li "…" }` | Bulleted list. `style:` is `:disc`, `:circle`, `:square`, `:dash` or any String; unstyled nested lists cycle disc → circle → square. Any node added inside (not only `li`) becomes an item. Items split across pages; the marker stays with the first line. |
|
|
90
92
|
| `ol(format:, start:, suffix:, gap:, indent:, marker_gap:, marker_color:) { }` | Numbered list. `format:` is `:decimal`, `:alpha`, `:upper_alpha`, `:roman`, `:upper_roman` or a Proc `(n) -> String`; `suffix:` defaults to `"."`. Markers right-align so bodies line up. |
|
|
91
93
|
| `li(string, **style)`, `li(gap:) { }` | A list item: a paragraph, or a block of any elements (including nested lists). |
|
|
@@ -93,14 +95,14 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
93
95
|
| `group(keep_together: true, align:) { }` | Keep a block on one page. |
|
|
94
96
|
| `keep_with_next: true \| points` | On `text`, `box` or `group`: never end a page with this node; with a number, keep at least that many points of what follows with it. |
|
|
95
97
|
| `text_style(**style) { }` | Default text style for a block. |
|
|
96
|
-
| `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links. |
|
|
98
|
+
| `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links; `rotate(degrees, around:) { }` and `transform([a, b, c, d, e, f]) { }` blocks. |
|
|
97
99
|
| `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, border:, background:, radius:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
|
|
98
100
|
| `checkbox(name, checked:, size:, label:, at:)` | An interactive check box, with an optional label drawn to its right. |
|
|
99
101
|
| `radio(name, value, checked:, size:, label:, at:)` | One choice of a radio group: radios sharing `name` form one field whose value is the checked `value`. |
|
|
100
102
|
| `select(name, options:, value:, width:, height:, editable:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
|
|
101
103
|
| `signature_field(name, width:, height:, label:, at:)` | An empty signature field for the signer to fill, drawn as a rule over the label. |
|
|
102
|
-
| `html(source, styles:, gap:, images:, base_path:, bookmarks:)` | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links. See [HTML and Markdown](#html-and-markdown). |
|
|
103
|
-
| `markdown(source, styles:, gap:, images:, base_path:, bookmarks:)` | The same from CommonMark (plus GFM tables and strikethrough). |
|
|
104
|
+
| `html(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links. See [HTML and Markdown](#html-and-markdown). |
|
|
105
|
+
| `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | The same from CommonMark (plus GFM tables and strikethrough). |
|
|
104
106
|
|
|
105
107
|
Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
|
|
106
108
|
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
@@ -128,7 +130,12 @@ end
|
|
|
128
130
|
points; bold, `keep_with_next`), `p`, `a` (colour, underline), `code` (`font:`; register a
|
|
129
131
|
monospace family for inline and block code, otherwise the text font is used), `pre` and
|
|
130
132
|
`blockquote` (box options), `hr` (rule options), `table` (`cell:` options, `header:` text style),
|
|
131
|
-
`
|
|
133
|
+
`ul` and `ol` (list options: `gap:`, `indent:`, `marker_gap:`, `marker_color:`, plus `style:` for
|
|
134
|
+
`ul` and `format:`/`suffix:` for `ol`), `li` (text style) and `img` (`max_width:`).
|
|
135
|
+
- Links are written only for `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`); anything
|
|
136
|
+
else (`javascript:`, `data:`, a relative path) keeps its text without a link and is reported as a
|
|
137
|
+
`DroppedLink` warning. `links: %w[http https]` changes the list, `links: :all` keeps every href
|
|
138
|
+
from a trusted source.
|
|
132
139
|
- `gap:` spaces the blocks (default 6); `bookmarks: true` adds h1–h3 to the PDF outline.
|
|
133
140
|
|
|
134
141
|
### Links, bookmarks and table of contents
|
|
@@ -393,6 +400,21 @@ that take an argument (`?id=42`); `?debug=1` passes `debug: true` to `to_pdf`
|
|
|
393
400
|
when the document supports it. Preview files are re-`load`ed on every request,
|
|
394
401
|
so edits show up on refresh.
|
|
395
402
|
|
|
403
|
+
Anything that must be in effect *while* the document renders (an I18n locale,
|
|
404
|
+
`CurrentAttributes`, a time zone) goes in `around_render`, which wraps both the
|
|
405
|
+
preview method and `to_pdf`:
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
class FlyerPdfPreview < Stationery::Preview
|
|
409
|
+
def month(params) = FlyerPdf.new(Event.upcoming)
|
|
410
|
+
|
|
411
|
+
def around_render(_name, params) = I18n.with_locale(params.fetch("locale", I18n.default_locale)) { yield }
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`/rails/stationery/previews/flyer_pdf/month?locale=de` renders in German.
|
|
416
|
+
`Preview#to_pdf(name, params, debug:)` is the same entry point for your own code.
|
|
417
|
+
|
|
396
418
|
The gem has no Rails dependency; the Railtie loads only inside a Rails app.
|
|
397
419
|
|
|
398
420
|
## CLI
|
|
@@ -481,9 +503,12 @@ end
|
|
|
481
503
|
```
|
|
482
504
|
|
|
483
505
|
Spaces, joiners, variation selectors and combining marks stay with the
|
|
484
|
-
character before them.
|
|
485
|
-
|
|
486
|
-
|
|
506
|
+
character before them. Whitespace no font has (an ideographic space U+3000,
|
|
507
|
+
a figure space, a narrow no-break space, …) is drawn as a blank of the
|
|
508
|
+
character's conventional width, never as `.notdef`. Any other glyph no font
|
|
509
|
+
has is drawn as the family's `.notdef` and reported as a
|
|
510
|
+
`Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises
|
|
511
|
+
on it). Fallback covers every text element,
|
|
487
512
|
table cell, list marker, table of contents entry and page template text;
|
|
488
513
|
direct `canvas.text` calls draw with the font they are given.
|
|
489
514
|
|
|
@@ -534,6 +559,7 @@ RSpec.describe InvoicePdf do
|
|
|
534
559
|
it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
|
|
535
560
|
it { is_expected.to have_image_count(1) }
|
|
536
561
|
it { is_expected.to have_no_warnings }
|
|
562
|
+
it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
|
|
537
563
|
it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
|
|
538
564
|
it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
|
|
539
565
|
end
|
|
@@ -554,6 +580,7 @@ class InvoicePdfTest < Minitest::Test
|
|
|
554
580
|
assert_page_count pdf, 2
|
|
555
581
|
assert_pdf_link pdf, /acme\.test/
|
|
556
582
|
assert_no_pdf_warnings pdf
|
|
583
|
+
assert_pdf_language pdf, "en"
|
|
557
584
|
assert_tagged_content pdf
|
|
558
585
|
assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
|
|
559
586
|
end
|
|
@@ -562,9 +589,10 @@ end
|
|
|
562
589
|
|
|
563
590
|
The matcher names carry a `pdf_` prefix so they never clash with Capybara's
|
|
564
591
|
`have_text` and `have_link`. `have_bookmark` / `assert_bookmark` match outline
|
|
565
|
-
titles.
|
|
592
|
+
titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and inside
|
|
593
|
+
`all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
|
|
566
594
|
exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
|
|
567
|
-
`image_count`, `bookmarks`, `metadata`, `warnings`, `tagged?`, `untagged_text` and
|
|
595
|
+
`image_count`, `bookmarks`, `metadata`, `lang`, `warnings`, `tagged?`, `untagged_text` and
|
|
568
596
|
`structure` — a tagged PDF's structure tree as nested arrays, each element's text
|
|
569
597
|
read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
|
|
570
598
|
between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
|
data/lib/stationery/builder.rb
CHANGED
|
@@ -62,6 +62,17 @@ module Stationery
|
|
|
62
62
|
@containers.pop
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Whether nodes are being added straight into a stack's flow (where a
|
|
66
|
+
# layer may go).
|
|
67
|
+
def in_stack? = stacks.include?(@containers.last)
|
|
68
|
+
|
|
69
|
+
def stack(flow, &)
|
|
70
|
+
stacks << flow
|
|
71
|
+
within(flow, &)
|
|
72
|
+
ensure
|
|
73
|
+
stacks.delete(flow)
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
def with_text(**overrides)
|
|
66
77
|
@text << text_defaults.merge(overrides.compact)
|
|
67
78
|
yield
|
|
@@ -70,6 +81,7 @@ module Stationery
|
|
|
70
81
|
end
|
|
71
82
|
|
|
72
83
|
def text_defaults = @text.last
|
|
84
|
+
def stacks = @stacks ||= []
|
|
73
85
|
def outline = @outline ||= Outline.new
|
|
74
86
|
def warnings = @book.warnings
|
|
75
87
|
|
|
@@ -9,7 +9,7 @@ module Stationery
|
|
|
9
9
|
DEBUG_COLORS = {
|
|
10
10
|
box: "#E11D48", padding: "#E11D48", column: "#2563EB", cell: "#16A34A", cell_padding: "#16A34A",
|
|
11
11
|
flow: "#9CA3AF", positioned: "#DB2777", image: "#0D9488", page: "#06B6D4", region: "#0EA5E9",
|
|
12
|
-
field: "#7C3AED"
|
|
12
|
+
field: "#7C3AED", stack: "#F59E0B"
|
|
13
13
|
}.freeze
|
|
14
14
|
DEBUG_DASHES = { padding: [2, 2], cell_padding: [2, 2], flow: [1, 2] }.freeze
|
|
15
15
|
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -31,6 +31,32 @@ module Stationery
|
|
|
31
31
|
emit("Q")
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Paints the block with the affine `matrix` [a, b, c, d, e, f] applied,
|
|
35
|
+
# given in top-left space (x' = ax + cy + e, y' = bx + dy + f with y
|
|
36
|
+
# growing downwards). Annotations (`link`, `widget`) keep their
|
|
37
|
+
# untransformed page rectangles.
|
|
38
|
+
def transform(matrix)
|
|
39
|
+
a, b, c, d, e, f = matrix
|
|
40
|
+
height = @page.height
|
|
41
|
+
pdf_matrix = [a, -b, -c, d, (c * height) + e, height - (d * height) - f]
|
|
42
|
+
save do
|
|
43
|
+
emit("#{pdf_matrix.map { |v| num(v) }.join(" ")} cm")
|
|
44
|
+
yield self
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Paints the block rotated `degrees` clockwise around the page point
|
|
49
|
+
# `around` ([x, y]), as CSS `rotate()` turns an element. Zero just yields.
|
|
50
|
+
def rotate(degrees, around:, &)
|
|
51
|
+
return yield self if degrees.zero?
|
|
52
|
+
|
|
53
|
+
radians = degrees * Math::PI / 180
|
|
54
|
+
cos = Math.cos(radians)
|
|
55
|
+
sin = Math.sin(radians)
|
|
56
|
+
cx, cy = around
|
|
57
|
+
transform([cos, sin, -sin, cos, cx - (cx * cos) + (cy * sin), cy - (cx * sin) - (cy * cos)], &)
|
|
58
|
+
end
|
|
59
|
+
|
|
34
60
|
def clip(x, y, w, h, radius: 0)
|
|
35
61
|
save do
|
|
36
62
|
emit(Path.new(self).rounded_rect(x, y, w, h, radius).to_s, "W n")
|
|
@@ -5,16 +5,18 @@ module Stationery
|
|
|
5
5
|
module Elements
|
|
6
6
|
# HTML such as ActionText's `record.body.to_s`. `images:` resolves an
|
|
7
7
|
# image src to a path or IO (nil skips it); otherwise it is read from
|
|
8
|
-
# under `base_path:`. Remote images are never fetched.
|
|
9
|
-
|
|
8
|
+
# under `base_path:`. Remote images are never fetched. `links:` lists the
|
|
9
|
+
# URL schemes written as links (default http, https, mailto and tel;
|
|
10
|
+
# `#anchor` always is); `:all` keeps every href for trusted sources.
|
|
11
|
+
def html(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
|
|
10
12
|
require_relative "../html/document"
|
|
11
|
-
rich(HTML.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:)
|
|
13
|
+
rich(HTML.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
# CommonMark (plus GFM tables and strikethrough); options as for html.
|
|
15
|
-
def markdown(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false)
|
|
17
|
+
def markdown(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
|
|
16
18
|
require_relative "../markdown/document"
|
|
17
|
-
rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:)
|
|
19
|
+
rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
private
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -51,6 +51,29 @@ module Stationery
|
|
|
51
51
|
@_builder.add(node)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# A base with layers painted over it: every ordinary child is part of the
|
|
55
|
+
# base (which sets the height), every `layer` floats over it relative to
|
|
56
|
+
# the stack's rectangle, taking no space. The stack moves to the next
|
|
57
|
+
# page whole. Layers may overhang; wrap the stack in `box(padding:)` to
|
|
58
|
+
# keep them inside the page.
|
|
59
|
+
def stack(gap: 0, align: nil, &)
|
|
60
|
+
flow = Layout::Flow.new([], gap:, align: align || :left)
|
|
61
|
+
@_builder.stack(flow) { align ? @_builder.with_text(align:) { yield_content(&) } : yield_content(&) }
|
|
62
|
+
layers, base = flow.children.partition { |child| child.is_a?(Layout::Layer) }
|
|
63
|
+
@_builder.add(Layout::Stack.new(flow.with_children(base), layers))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A box placed over the enclosing `stack` by insets from its edges:
|
|
67
|
+
# points, or a fraction (`0.4`, `1/3r`) of the stack's width or height;
|
|
68
|
+
# negative values overhang. Takes every box option (`rotate:`, `shadow:`,
|
|
69
|
+
# `padding:`, `background:`, `radius:`, `height:`, …).
|
|
70
|
+
def layer(top: nil, right: nil, bottom: nil, left: nil, width: nil, height: nil, align: nil, gap: 0, **, &)
|
|
71
|
+
raise ArgumentError, "layer must be inside a stack" unless @_builder.in_stack?
|
|
72
|
+
|
|
73
|
+
box = Layout::Box.new(container(align:, gap:, &), **)
|
|
74
|
+
@_builder.add(Layout::Layer.new(box, top:, right:, bottom:, left:, width:, height:))
|
|
75
|
+
end
|
|
76
|
+
|
|
54
77
|
# Children kept in one vertical group; `keep_together: true` moves the
|
|
55
78
|
# whole group to the next page rather than splitting it.
|
|
56
79
|
def group(gap: 0, align: nil, keep_together: false, keep_with_next: nil, anchor: nil, bookmark: nil, &)
|
|
@@ -97,7 +120,8 @@ module Stationery
|
|
|
97
120
|
@_builder.add(node)
|
|
98
121
|
end
|
|
99
122
|
|
|
100
|
-
# `alt:` describes the image in a tagged PDF (false: decorative).
|
|
123
|
+
# `alt:` describes the image in a tagged PDF (false: decorative). Sizing,
|
|
124
|
+
# `fit: :cover`, `radius:` and `rotate:` as for Layout::Image.
|
|
101
125
|
def image(source, align: nil, **)
|
|
102
126
|
node = Layout::Image.new(source, **)
|
|
103
127
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
@@ -6,11 +6,13 @@ module Stationery
|
|
|
6
6
|
# run's family, then the book's fallbacks in order, then bundled Inter. A
|
|
7
7
|
# glyph no font has stays in the run's family and draws as .notdef.
|
|
8
8
|
#
|
|
9
|
-
# Whitespace, joiners, variation selectors and
|
|
10
|
-
# family of the character before them (or
|
|
11
|
-
# run) so words are not chopped and marks
|
|
9
|
+
# Whitespace (any Unicode White_Space), joiners, variation selectors and
|
|
10
|
+
# combining marks carry the family of the character before them (or
|
|
11
|
+
# after them at the start of a run) so words are not chopped and marks
|
|
12
|
+
# stay with their base; whitespace a font lacks draws as a blank of the
|
|
13
|
+
# right width (see Font::WHITESPACE).
|
|
12
14
|
class Fallback
|
|
13
|
-
CARRIED = /[\
|
|
15
|
+
CARRIED = /[\p{Space}︀-️\p{M}]/
|
|
14
16
|
|
|
15
17
|
def self.carried?(char) = CARRIED.match?(char)
|
|
16
18
|
|
|
@@ -12,6 +12,18 @@ module Stationery
|
|
|
12
12
|
class Font
|
|
13
13
|
OBLIQUE_SKEW = Math.tan(12 * Math::PI / 180)
|
|
14
14
|
|
|
15
|
+
# Whitespace the font lacks draws as its space glyph advanced to the
|
|
16
|
+
# character's conventional width: a fraction of the em for the fixed
|
|
17
|
+
# spaces, a digit's or a period's advance for the figure and
|
|
18
|
+
# punctuation spaces, the space's own width for the rest (no-break,
|
|
19
|
+
# line and paragraph separators, ogham mark, …). Nothing is painted,
|
|
20
|
+
# nothing is .notdef.
|
|
21
|
+
WHITESPACE = /\A\p{Space}\z/
|
|
22
|
+
SPACE_FRACTIONS = { 0x2000 => 0.5, 0x2001 => 1.0, 0x2002 => 0.5, 0x2003 => 1.0, 0x2004 => 1.0 / 3,
|
|
23
|
+
0x2005 => 0.25, 0x2006 => 1.0 / 6, 0x2009 => 0.2, 0x200A => 0.125, 0x205F => 4.0 / 18,
|
|
24
|
+
0x3000 => 1.0 }.freeze
|
|
25
|
+
SPACE_LIKE = { 0x2007 => "0", 0x2008 => "." }.freeze
|
|
26
|
+
|
|
15
27
|
attr_reader :ttf
|
|
16
28
|
|
|
17
29
|
def initialize(ttf)
|
|
@@ -19,6 +31,7 @@ module Stationery
|
|
|
19
31
|
@used = {}
|
|
20
32
|
@pairs = {}
|
|
21
33
|
@glyphs = {}
|
|
34
|
+
@blanks = {}
|
|
22
35
|
@shapes = { true => {}, false => {} }
|
|
23
36
|
@advances = { true => {}, false => {} }
|
|
24
37
|
@kerns = { true => {}, false => {} }
|
|
@@ -59,12 +72,20 @@ module Stationery
|
|
|
59
72
|
# `ligatures:` substitutes the font's standard ligatures; `kerning:`
|
|
60
73
|
# then fills the adjustments with pair kerning between the glyphs.
|
|
61
74
|
def glyph_run(text, kerning: false, ligatures: true)
|
|
62
|
-
gids, chars = shape(text, ligatures)
|
|
63
|
-
gids.each_with_index { |gid, i| @used[gid] ||= chars[i] }
|
|
64
|
-
adjust = gids.each_with_index.map
|
|
75
|
+
gids, chars, blanks = shape(text, ligatures)
|
|
76
|
+
gids.each_with_index { |gid, i| @used[gid] ||= blanks[i] ? " " : chars[i] }
|
|
77
|
+
adjust = gids.each_with_index.map do |gid, i|
|
|
78
|
+
kern = kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0
|
|
79
|
+
blanks[i] ? kern + (blanks[i] * 1000.0 / @ttf.units_per_em) : kern
|
|
80
|
+
end
|
|
65
81
|
GlyphRun.new(font: self, gids:, adjust:, chars:)
|
|
66
82
|
end
|
|
67
83
|
|
|
84
|
+
# Whether a character the font lacks is drawn as a blank (see WHITESPACE).
|
|
85
|
+
def blank?(char)
|
|
86
|
+
@blanks.fetch(char) { @blanks[char] = WHITESPACE.match?(char) && !glyph?(char) && glyph?(" ") }
|
|
87
|
+
end
|
|
88
|
+
|
|
68
89
|
def used?
|
|
69
90
|
@used.any?
|
|
70
91
|
end
|
|
@@ -93,11 +114,13 @@ module Stationery
|
|
|
93
114
|
|
|
94
115
|
private
|
|
95
116
|
|
|
96
|
-
# [gids, source text of each glyph
|
|
117
|
+
# [gids, source text of each glyph, extra advance in font units after
|
|
118
|
+
# each blank or nil], remembered per string.
|
|
97
119
|
def shape(text, ligatures)
|
|
98
120
|
@shapes[ligatures][text] ||= begin
|
|
99
|
-
gids = text.each_char.map { |char|
|
|
100
|
-
ligatures ? ligate(gids, text) : [gids, text.chars]
|
|
121
|
+
gids = text.each_char.map { |char| glyph_for(char) }
|
|
122
|
+
gids, chars = ligatures ? ligate(gids, text) : [gids, text.chars]
|
|
123
|
+
[gids, chars, chars.map { |char| blank_units(char) }].each(&:freeze).freeze
|
|
101
124
|
end
|
|
102
125
|
end
|
|
103
126
|
|
|
@@ -105,11 +128,31 @@ module Stationery
|
|
|
105
128
|
start = 0
|
|
106
129
|
glyphs = @ttf.ligatures.substitute(gids)
|
|
107
130
|
chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
|
|
108
|
-
[glyphs.map(&:first), chars]
|
|
131
|
+
[glyphs.map(&:first), chars]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def glyph_for(char)
|
|
135
|
+
blank?(char) ? @ttf.glyph_id(" ".ord) : @ttf.glyph_id(char.ord)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# How much wider (or narrower) than a space a blank's advance is.
|
|
139
|
+
def blank_units(char)
|
|
140
|
+
return unless blank?(char)
|
|
141
|
+
|
|
142
|
+
space = @ttf.advance(@ttf.glyph_id(" ".ord))
|
|
143
|
+
like = SPACE_LIKE[char.ord]
|
|
144
|
+
width = if (fraction = SPACE_FRACTIONS[char.ord]) then @ttf.units_per_em * fraction
|
|
145
|
+
elsif like && glyph?(like) then @ttf.advance(@ttf.glyph_id(like.ord))
|
|
146
|
+
else space
|
|
147
|
+
end
|
|
148
|
+
width - space
|
|
109
149
|
end
|
|
110
150
|
|
|
111
151
|
def advance_units(text, ligatures)
|
|
112
|
-
@advances[ligatures][text] ||=
|
|
152
|
+
@advances[ligatures][text] ||= begin
|
|
153
|
+
gids, _, blanks = shape(text, ligatures)
|
|
154
|
+
gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 }
|
|
155
|
+
end
|
|
113
156
|
end
|
|
114
157
|
|
|
115
158
|
def kerning_units(text, ligatures)
|
|
@@ -10,15 +10,20 @@ module Stationery
|
|
|
10
10
|
# fixed-height box can truncate or shrink text that does not fit
|
|
11
11
|
# (`overflow:`) and never splits. `min_height:` is a floor that does split:
|
|
12
12
|
# the first fragment keeps as much of it as the page holds and the next
|
|
13
|
-
# one carries only what is left of it.
|
|
13
|
+
# one carries only what is left of it. `rotate:` turns the painted box
|
|
14
|
+
# around its centre without moving its layout rectangle (a rotated box
|
|
15
|
+
# never splits); `shadow:` paints a soft drop shadow under it, taking no
|
|
16
|
+
# space; `overflow: :hidden` clips the content to the rounded outline.
|
|
14
17
|
class Box < Node
|
|
15
18
|
BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
|
|
19
|
+
SHADOW = { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }.freeze
|
|
20
|
+
SHADOW_LAYERS = 8
|
|
16
21
|
|
|
17
22
|
attr_reader :content, :width_spec
|
|
18
23
|
|
|
19
24
|
def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
|
|
20
25
|
height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
|
|
21
|
-
open: [], decoration: :slice, role: nil)
|
|
26
|
+
open: [], decoration: :slice, role: nil, rotate: 0, shadow: nil)
|
|
22
27
|
raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
|
|
23
28
|
|
|
24
29
|
super()
|
|
@@ -39,6 +44,8 @@ module Stationery
|
|
|
39
44
|
@overflow = overflow
|
|
40
45
|
@valign = valign
|
|
41
46
|
@opacity = opacity
|
|
47
|
+
@rotate = rotate
|
|
48
|
+
@shadow = shadow && SHADOW.merge(shadow == true ? {} : shadow)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
def natural_width
|
|
@@ -55,7 +62,10 @@ module Stationery
|
|
|
55
62
|
end
|
|
56
63
|
end
|
|
57
64
|
|
|
58
|
-
def splittable?
|
|
65
|
+
def splittable?
|
|
66
|
+
@height.nil? && @rotate.zero? && %i[visible hidden].include?(@overflow) && @content.splittable?
|
|
67
|
+
end
|
|
68
|
+
|
|
59
69
|
def prefer_whole? = break_inside.nil? && splittable?
|
|
60
70
|
|
|
61
71
|
def split(width, height, fresh: false)
|
|
@@ -83,15 +93,18 @@ module Stationery
|
|
|
83
93
|
|
|
84
94
|
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
|
|
85
95
|
height ||= measure(width)
|
|
86
|
-
canvas.
|
|
87
|
-
canvas.structure(@
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
canvas.rotate(@rotate, around: [x + (width / 2.0), y + (height / 2.0)]) do
|
|
97
|
+
canvas.structure(@tag) do
|
|
98
|
+
canvas.structure(@link_tag) do
|
|
99
|
+
paint_shadow(canvas, x, y, width, height)
|
|
100
|
+
paint_background(canvas, x, y, width, height)
|
|
101
|
+
paint_border(canvas, x, y, width, height)
|
|
102
|
+
paint_content(canvas, x, y, width, height, valign || @valign)
|
|
103
|
+
end
|
|
91
104
|
end
|
|
105
|
+
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
92
106
|
end
|
|
93
107
|
canvas.link(x, y, width, height, @link, tag: @link_tag) if @link
|
|
94
|
-
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
95
108
|
end
|
|
96
109
|
|
|
97
110
|
protected
|
|
@@ -125,6 +138,27 @@ module Stationery
|
|
|
125
138
|
def vertical(open = @open) = insets(open).values_at(0, 2).sum
|
|
126
139
|
def inner_width(width) = [width - horizontal, 0].max
|
|
127
140
|
|
|
141
|
+
# Stacked rounded rectangles under the box, the outermost grown by the
|
|
142
|
+
# blur and each fainter, so the centre sums to the opacity and the edge
|
|
143
|
+
# fades. An artifact: decoration, not content. Skipped on a fragment
|
|
144
|
+
# with cut sides, whose corners are square anyway.
|
|
145
|
+
def paint_shadow(canvas, x, y, width, height)
|
|
146
|
+
return unless @shadow && @open.empty?
|
|
147
|
+
|
|
148
|
+
blur = @shadow[:blur].to_f
|
|
149
|
+
layers = blur.positive? ? [(blur / 2).ceil, SHADOW_LAYERS].min : 1
|
|
150
|
+
dx, dy = @shadow[:offset]
|
|
151
|
+
canvas.artifact do
|
|
152
|
+
layers.downto(1) do |layer|
|
|
153
|
+
grow = blur * layer / layers
|
|
154
|
+
canvas.rounded_rect(x + dx - grow, y + dy - grow, width + (2 * grow), height + (2 * grow),
|
|
155
|
+
radius: grown_radius(grow), fill: @shadow[:color], opacity: @shadow[:opacity] / layers)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def grown_radius(grow) = @radius.is_a?(Array) ? @radius.map { |r| r + grow } : @radius + grow
|
|
161
|
+
|
|
128
162
|
# The outset paints the background past the box's own edges (a band that
|
|
129
163
|
# bleeds into the page margins) without moving the content.
|
|
130
164
|
def paint_background(canvas, x, y, width, height)
|
|
@@ -190,15 +224,27 @@ module Stationery
|
|
|
190
224
|
end,
|
|
191
225
|
inner.height, used)
|
|
192
226
|
paint_inner = -> { content.paint(canvas, x + left, y + top + [offset, 0].max, inner.width) }
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
else
|
|
196
|
-
canvas.clip(inner.x, inner.y, inner.width, inner.height) do
|
|
227
|
+
clip_outline(canvas, Rect.new(x, y, width, height)) do
|
|
228
|
+
if @overflow == :visible || (@overflow == :hidden && @height.nil?)
|
|
197
229
|
paint_inner.call
|
|
230
|
+
else
|
|
231
|
+
canvas.clip(inner.x, inner.y, inner.width, inner.height) do
|
|
232
|
+
paint_inner.call
|
|
233
|
+
end
|
|
198
234
|
end
|
|
199
235
|
end
|
|
200
236
|
end
|
|
201
237
|
|
|
238
|
+
# `overflow: :hidden` keeps the content inside the rounded outline; cut
|
|
239
|
+
# sides stay square like the background's.
|
|
240
|
+
def clip_outline(canvas, area, &)
|
|
241
|
+
return yield unless @overflow == :hidden && @radius.positive?
|
|
242
|
+
|
|
243
|
+
cut(canvas, area, area) do |shape|
|
|
244
|
+
canvas.clip(shape.x, shape.y, shape.width, shape.height, radius: @radius, &)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
202
248
|
def paint_debug(canvas, rect, kind)
|
|
203
249
|
canvas.debug_rect(rect.x, rect.y, rect.width, rect.height, kind)
|
|
204
250
|
return if insets.all?(&:zero?)
|
|
@@ -4,10 +4,16 @@ module Stationery
|
|
|
4
4
|
module Layout
|
|
5
5
|
# An image sized by width, height, both, or a box to fit into (aspect
|
|
6
6
|
# preserved), never wider than the space it is given. One pixel is one
|
|
7
|
-
# point when no size is given.
|
|
7
|
+
# point when no size is given. `fit: :cover` fills `width:` × `height:`
|
|
8
|
+
# instead, scaling up and clipping the excess around the centre.
|
|
9
|
+
# `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
|
|
10
|
+
# turns the painted image around the centre of its rectangle; neither
|
|
11
|
+
# changes the space the image takes up.
|
|
8
12
|
class Image < Node
|
|
9
13
|
# `alt:` describes the image in a tagged PDF; `alt: false` marks it decorative.
|
|
10
|
-
def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil)
|
|
14
|
+
def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil, radius: 0, rotate: 0)
|
|
15
|
+
raise ArgumentError, "fit: :cover needs width: and height:" if fit == :cover && !(width && height)
|
|
16
|
+
|
|
11
17
|
super()
|
|
12
18
|
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
13
19
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
@@ -15,6 +21,8 @@ module Stationery
|
|
|
15
21
|
@height = height
|
|
16
22
|
@fit = fit
|
|
17
23
|
@opacity = opacity
|
|
24
|
+
@radius = radius
|
|
25
|
+
@rotate = rotate
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def size(available)
|
|
@@ -31,16 +39,37 @@ module Stationery
|
|
|
31
39
|
|
|
32
40
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
33
41
|
w, h = size(width)
|
|
34
|
-
canvas.tag(@tag, bbox: [x, y, w, h])
|
|
42
|
+
canvas.tag(@tag, bbox: [x, y, w, h]) do
|
|
43
|
+
canvas.rotate(@rotate, around: [x + (w / 2.0), y + (h / 2.0)]) { paint_clipped(canvas, x, y, w, h) }
|
|
44
|
+
end
|
|
35
45
|
canvas.debug_rect(x, y, w, h, :image)
|
|
36
46
|
end
|
|
37
47
|
|
|
38
48
|
private
|
|
39
49
|
|
|
50
|
+
def paint_clipped(canvas, x, y, w, h)
|
|
51
|
+
return paint_image(canvas, x, y, w, h) unless @fit == :cover || @radius.positive?
|
|
52
|
+
|
|
53
|
+
canvas.clip(x, y, w, h, radius: @radius) { paint_image(canvas, x, y, w, h) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def paint_image(canvas, x, y, w, h)
|
|
57
|
+
if @fit == :cover
|
|
58
|
+
scale = [w / @image.width.to_f, h / @image.height.to_f].max
|
|
59
|
+
cw = tidy(@image.width * scale)
|
|
60
|
+
ch = tidy(@image.height * scale)
|
|
61
|
+
x = tidy(x - ((cw - w) / 2.0))
|
|
62
|
+
y = tidy(y - ((ch - h) / 2.0))
|
|
63
|
+
w = cw
|
|
64
|
+
h = ch
|
|
65
|
+
end
|
|
66
|
+
canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
|
|
67
|
+
end
|
|
68
|
+
|
|
40
69
|
def requested
|
|
41
70
|
iw = @image.width.to_f
|
|
42
71
|
ih = @image.height.to_f
|
|
43
|
-
if @fit
|
|
72
|
+
if @fit.is_a?(Array)
|
|
44
73
|
scale = [@fit[0] / iw, @fit[1] / ih].min
|
|
45
74
|
[iw * scale, ih * scale].map { |v| tidy(v) }
|
|
46
75
|
elsif @width && @height then [@width, @height]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Layout
|
|
5
|
+
# A base flow with layers painted over it. The base sets the height and
|
|
6
|
+
# widths; each Layer is placed relative to the stack's rectangle and takes
|
|
7
|
+
# no space, so it can overhang the edges. A stack never splits: it moves
|
|
8
|
+
# to the next page whole (and is reported as an Overflow when taller than
|
|
9
|
+
# a page).
|
|
10
|
+
class Stack < Node
|
|
11
|
+
attr_reader :base, :layers
|
|
12
|
+
|
|
13
|
+
def initialize(base, layers)
|
|
14
|
+
super()
|
|
15
|
+
@base = base
|
|
16
|
+
@layers = layers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def measure(width) = @base.measure(width)
|
|
20
|
+
def natural_width = @base.natural_width
|
|
21
|
+
def min_width = @base.min_width
|
|
22
|
+
def fixed_width(available) = @base.fixed_width(available)
|
|
23
|
+
def prefer_whole? = true
|
|
24
|
+
|
|
25
|
+
def paint(canvas, x, y, width, height = nil, **)
|
|
26
|
+
height ||= measure(width)
|
|
27
|
+
@base.paint(canvas, x, y, width, height)
|
|
28
|
+
@layers.each { |layer| layer.paint(canvas, x, y, width, height) }
|
|
29
|
+
canvas.debug_rect(x, y, width, height, :stack)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A box placed over a Stack by CSS-like insets. `top:`, `right:`,
|
|
34
|
+
# `bottom:`, `left:`, `width:` and `height:` are points, or fractions of
|
|
35
|
+
# the stack's width (horizontal ones) or height (vertical ones) when given
|
|
36
|
+
# as a Rational or a Float between -1 and 1; negative points overhang.
|
|
37
|
+
# Without `left:`/`top:` the layer sits at the stack's left/top edge;
|
|
38
|
+
# without `width:` it takes its content's natural width, at most the
|
|
39
|
+
# stack's, and without `height:` its content's height.
|
|
40
|
+
class Layer
|
|
41
|
+
def initialize(box, top: nil, right: nil, bottom: nil, left: nil, width: nil, height: nil)
|
|
42
|
+
@box = box
|
|
43
|
+
@top = top
|
|
44
|
+
@right = right
|
|
45
|
+
@bottom = bottom
|
|
46
|
+
@left = left
|
|
47
|
+
@width = width
|
|
48
|
+
@height = height
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def paint(canvas, x, y, width, height)
|
|
52
|
+
own = layer_width(width)
|
|
53
|
+
tall = @height ? resolve(@height, height) : @box.measure(own)
|
|
54
|
+
@box.paint(canvas, x + offset(@left, @right, width, own), y + offset(@top, @bottom, height, tall), own, tall)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Where the layer's near edge sits along one axis of the stack: `start`
|
|
60
|
+
# points from the start edge, else `finish` points before the end edge.
|
|
61
|
+
def offset(start, finish, extent, own)
|
|
62
|
+
return resolve(start, extent) if start
|
|
63
|
+
return extent - resolve(finish, extent) - own if finish
|
|
64
|
+
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def layer_width(width)
|
|
69
|
+
return [@box.natural_width, width].min unless @width
|
|
70
|
+
|
|
71
|
+
resolve(@width, width)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve(value, extent)
|
|
75
|
+
fraction?(value) ? extent * value : value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fraction?(value) = value.is_a?(Rational) || (value.is_a?(Float) && value.abs <= 1)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Stationery
|
|
|
16
16
|
def assert_pdf_link(subject, url, msg = nil) = assert_pdf(Matchers::HaveLink.new(url), subject, msg)
|
|
17
17
|
def assert_image_count(subject, count, msg = nil) = assert_pdf(Matchers::HaveImageCount.new(count), subject, msg)
|
|
18
18
|
def assert_bookmark(subject, title, msg = nil) = assert_pdf(Matchers::HaveBookmark.new(title), subject, msg)
|
|
19
|
+
def assert_pdf_language(subject, lang, msg = nil) = assert_pdf(Matchers::HaveLanguage.new(lang), subject, msg)
|
|
19
20
|
def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
|
|
20
21
|
def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
|
|
21
22
|
def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
|
data/lib/stationery/preview.rb
CHANGED
|
@@ -4,15 +4,19 @@ require "stationery"
|
|
|
4
4
|
|
|
5
5
|
module Stationery
|
|
6
6
|
# Browsable sample documents, like ActionMailer previews. Each public method
|
|
7
|
-
# returns one document
|
|
7
|
+
# returns one document. `around_render` wraps building and rendering it,
|
|
8
|
+
# for anything that must be in effect while the document paints:
|
|
8
9
|
#
|
|
9
10
|
# # spec/pdfs/previews/invoice_pdf_preview.rb
|
|
10
11
|
# class InvoicePdfPreview < Stationery::Preview
|
|
11
12
|
# def paid = InvoicePdf.new(Invoice.first)
|
|
12
13
|
# def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id")))
|
|
14
|
+
#
|
|
15
|
+
# def around_render(_name, params) = I18n.with_locale(params.fetch("locale", I18n.default_locale)) { yield }
|
|
13
16
|
# end
|
|
14
17
|
class Preview
|
|
15
18
|
REGISTRY_LOCK = Mutex.new
|
|
19
|
+
HOOKS = %w[around_render].freeze
|
|
16
20
|
|
|
17
21
|
class << self
|
|
18
22
|
def inherited(subclass)
|
|
@@ -40,7 +44,7 @@ module Stationery
|
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def preview_name = underscore(name.delete_suffix("Preview"))
|
|
43
|
-
def pdfs = public_instance_methods(false).sort.map(&:to_s)
|
|
47
|
+
def pdfs = public_instance_methods(false).sort.map(&:to_s) - HOOKS
|
|
44
48
|
|
|
45
49
|
protected
|
|
46
50
|
|
|
@@ -53,6 +57,19 @@ module Stationery
|
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
|
|
60
|
+
# The PDF bytes of one preview, built and rendered inside around_render.
|
|
61
|
+
# `debug: true` reaches to_pdf when the document accepts it.
|
|
62
|
+
def to_pdf(name, params = {}, debug: false)
|
|
63
|
+
around_render(name, params) do
|
|
64
|
+
document = render(name, params)
|
|
65
|
+
document.to_pdf(**(debug && accepts_debug?(document) ? { debug: true } : {}))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Override to wrap the preview: set an I18n locale, Current attributes,
|
|
70
|
+
# a time zone. Both the preview method and to_pdf run inside the block.
|
|
71
|
+
def around_render(_name, _params) = yield
|
|
72
|
+
|
|
56
73
|
def render(name, params = {})
|
|
57
74
|
method = public_method(name)
|
|
58
75
|
document = method.arity.zero? ? method.call : method.call(params)
|
|
@@ -60,5 +77,9 @@ module Stationery
|
|
|
60
77
|
|
|
61
78
|
raise Error, "#{self.class}##{name} must return a Stationery::Document (got #{document.class})"
|
|
62
79
|
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def accepts_debug?(document) = document.method(:to_pdf).parameters.include?(%i[key debug])
|
|
63
84
|
end
|
|
64
85
|
end
|
|
@@ -19,8 +19,8 @@ module Stationery
|
|
|
19
19
|
klass, pdf = Preview.find(params[:path])
|
|
20
20
|
return head(:not_found) unless klass
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
send_data
|
|
22
|
+
pdf = klass.new.to_pdf(pdf, request.query_parameters.except("debug"), debug: params[:debug].present?)
|
|
23
|
+
send_data pdf, type: "application/pdf", disposition: "inline"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
@@ -35,11 +35,6 @@ module Stationery
|
|
|
35
35
|
Preview.load(Railtie.preview_paths(::Rails.root, config.preview_paths))
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def debug_option(document)
|
|
39
|
-
debug = params[:debug].present? && document.method(:to_pdf).parameters.include?(%i[key debug])
|
|
40
|
-
debug ? { debug: true } : {}
|
|
41
|
-
end
|
|
42
|
-
|
|
43
38
|
def page(paths)
|
|
44
39
|
items = paths.map do |path|
|
|
45
40
|
href = ERB::Util.html_escape("#{request.path.chomp("/")}/#{path}")
|
|
@@ -19,16 +19,24 @@ module Stationery
|
|
|
19
19
|
bold: [[:b]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
def initialize(runs, styles)
|
|
22
|
+
def initialize(runs, styles, links = Links.new(:all, nil))
|
|
23
23
|
@runs = runs
|
|
24
24
|
@styles = styles
|
|
25
|
+
@links = links
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def write(inlines) = inlines.grep(Inline).each { |inline| inline.break? ? @runs.br : write_one(inline) }
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
31
|
-
def write_one(inline) = nest(inline.
|
|
32
|
+
def write_one(inline) = nest(marks_of(inline).flat_map { |mark, value| steps(mark, value) }, inline.text)
|
|
33
|
+
|
|
34
|
+
def marks_of(inline)
|
|
35
|
+
marks = inline.marks
|
|
36
|
+
return marks unless marks.key?(:link) && !@links.allowed?(marks[:link])
|
|
37
|
+
|
|
38
|
+
marks.except(:link)
|
|
39
|
+
end
|
|
32
40
|
|
|
33
41
|
def nest(steps, text)
|
|
34
42
|
return @runs.plain(text) if steps.empty?
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# Which hrefs become link annotations: `#anchor` always, otherwise only
|
|
7
|
+
# the allowed URL schemes (case-insensitive). A dropped href keeps its
|
|
8
|
+
# text and is reported once as a DroppedLink warning. `:all` keeps every
|
|
9
|
+
# href, for trusted sources.
|
|
10
|
+
class Links
|
|
11
|
+
SCHEME = /\A([a-z][a-z0-9+.-]*):/i
|
|
12
|
+
|
|
13
|
+
def initialize(schemes, warnings)
|
|
14
|
+
@schemes = schemes == :all ? :all : Array(schemes).map { |scheme| scheme.to_s.downcase }
|
|
15
|
+
@warnings = warnings
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def allowed?(href)
|
|
19
|
+
return true if @schemes == :all || href.start_with?("#")
|
|
20
|
+
return true if @schemes.include?(href[SCHEME, 1].to_s.downcase)
|
|
21
|
+
|
|
22
|
+
@warnings << Warnings::DroppedLink.new(href:)
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "nodes"
|
|
4
4
|
require_relative "styles"
|
|
5
5
|
require_relative "renderer/inlines"
|
|
6
|
+
require_relative "renderer/links"
|
|
6
7
|
|
|
7
8
|
module Stationery
|
|
8
9
|
module Rich
|
|
@@ -11,8 +12,9 @@ module Stationery
|
|
|
11
12
|
# from files under `base_path:`; remote URLs are never fetched.
|
|
12
13
|
class Renderer
|
|
13
14
|
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
15
|
+
LINK_SCHEMES = %w[http https mailto tel].freeze
|
|
14
16
|
|
|
15
|
-
def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false)
|
|
17
|
+
def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false, links: nil)
|
|
16
18
|
@component = component
|
|
17
19
|
@builder = builder
|
|
18
20
|
@gap = gap
|
|
@@ -20,6 +22,7 @@ module Stationery
|
|
|
20
22
|
@images = images
|
|
21
23
|
@base_path = base_path && File.expand_path(base_path.to_s)
|
|
22
24
|
@bookmarks = bookmarks
|
|
25
|
+
@links = Links.new(links || LINK_SCHEMES, builder.warnings)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def render(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
|
|
@@ -40,7 +43,7 @@ module Stationery
|
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def paragraph(inlines, **)
|
|
43
|
-
@component.text(**) { |runs| Inlines.new(runs, @styles).write(inlines) }
|
|
46
|
+
@component.text(**) { |runs| Inlines.new(runs, @styles, @links).write(inlines) }
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
def heading(heading)
|
|
@@ -57,7 +60,7 @@ module Stationery
|
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
def list(list)
|
|
60
|
-
options = list.ordered ? { start: list.start || 1 } :
|
|
63
|
+
options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
|
|
61
64
|
@component.public_send(list.ordered ? :ol : :ul, **options) do
|
|
62
65
|
list.items.each do |blocks|
|
|
63
66
|
@component.li { @component.text_style(**@styles[:li]) { render(blocks) } }
|
|
@@ -18,6 +18,8 @@ module Stationery
|
|
|
18
18
|
blockquote: { border: { sides: [:left], width: 3, color: "#E5E7EB" }, padding: [0, 0, 0, 10] },
|
|
19
19
|
hr: { height: 1, color: "#E5E7EB" },
|
|
20
20
|
table: { cell: { padding: 4 }, header: { weight: :bold } },
|
|
21
|
+
ul: {},
|
|
22
|
+
ol: {},
|
|
21
23
|
li: {},
|
|
22
24
|
img: { max_width: nil }
|
|
23
25
|
}.freeze
|
data/lib/stationery/rspec.rb
CHANGED
|
@@ -3,4 +3,8 @@
|
|
|
3
3
|
require "stationery"
|
|
4
4
|
require_relative "testing/matchers"
|
|
5
5
|
|
|
6
|
+
# Composable adds `and`/`or` and lets a matcher sit inside `include`, `match`
|
|
7
|
+
# and `all`; Base stays framework-free for Minitest, so it is mixed in here.
|
|
8
|
+
Stationery::Testing::Matchers::Base.include(RSpec::Matchers::Composable)
|
|
9
|
+
|
|
6
10
|
RSpec.configure { |config| config.include Stationery::Testing::Matchers }
|
|
@@ -60,16 +60,21 @@ module Stationery
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def bookmarks
|
|
63
|
-
outlines =
|
|
63
|
+
outlines = catalog[:Outlines]
|
|
64
64
|
outlines ? titles(outlines[:First]) : []
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# The catalog /Lang written by `metadata lang:`, or nil.
|
|
68
|
+
def lang
|
|
69
|
+
lang = catalog[:Lang]
|
|
70
|
+
decode(lang) if lang
|
|
71
|
+
end
|
|
72
|
+
|
|
67
73
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
68
74
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
69
75
|
|
|
70
76
|
# Whether the catalog marks the PDF as tagged and holds a structure tree.
|
|
71
77
|
def tagged?
|
|
72
|
-
catalog = objects.deref!(objects.trailer[:Root])
|
|
73
78
|
catalog.dig(:MarkInfo, :Marked) == true && !catalog[:StructTreeRoot].nil?
|
|
74
79
|
end
|
|
75
80
|
|
|
@@ -81,6 +86,7 @@ module Stationery
|
|
|
81
86
|
private
|
|
82
87
|
|
|
83
88
|
def objects = reader.objects
|
|
89
|
+
def catalog = objects.deref!(objects.trailer[:Root])
|
|
84
90
|
|
|
85
91
|
def annotations
|
|
86
92
|
reader.pages.flat_map do |page|
|
|
@@ -101,6 +101,15 @@ module Stationery
|
|
|
101
101
|
def actual = "got bookmarks #{@inspector.bookmarks.inspect}"
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
class HaveLanguage < Base
|
|
105
|
+
def description = "have language #{show(@expected)}"
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def match?(pdf) = pdf.lang == @expected.to_s
|
|
110
|
+
def actual = @inspector.lang ? "got #{@inspector.lang.inspect}" : "got none"
|
|
111
|
+
end
|
|
112
|
+
|
|
104
113
|
class HaveNoWarnings < Base
|
|
105
114
|
def description = "have no warnings"
|
|
106
115
|
|
|
@@ -140,6 +149,7 @@ module Stationery
|
|
|
140
149
|
def have_pdf_link(expected) = HaveLink.new(expected)
|
|
141
150
|
def have_image_count(expected) = HaveImageCount.new(expected)
|
|
142
151
|
def have_bookmark(title) = HaveBookmark.new(title)
|
|
152
|
+
def have_pdf_language(lang) = HaveLanguage.new(lang)
|
|
143
153
|
def have_no_warnings = HaveNoWarnings.new
|
|
144
154
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
145
155
|
def have_tagged_content = HaveTaggedContent.new
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery/warnings.rb
CHANGED
|
@@ -33,6 +33,10 @@ module Stationery
|
|
|
33
33
|
def message = %(link to "#{name}" on page #{page} has no matching anchor)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
DroppedLink = Data.define(:href) do
|
|
37
|
+
def message = %(link "#{href}" dropped: scheme not allowed)
|
|
38
|
+
end
|
|
39
|
+
|
|
36
40
|
DuplicateAnchor = Data.define(:name, :page) do
|
|
37
41
|
def message = %(anchor "#{name}" on page #{page} is already defined)
|
|
38
42
|
end
|
data/lib/stationery.rb
CHANGED
|
@@ -107,6 +107,7 @@ require_relative "stationery/svg/document"
|
|
|
107
107
|
require_relative "stationery/layout/svg"
|
|
108
108
|
require_relative "stationery/layout/wrap"
|
|
109
109
|
require_relative "stationery/layout/positioned"
|
|
110
|
+
require_relative "stationery/layout/stack"
|
|
110
111
|
require_relative "stationery/layout/field"
|
|
111
112
|
require_relative "stationery/layout/list_item"
|
|
112
113
|
require_relative "stationery/list_markers"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stationery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -110,6 +110,7 @@ files:
|
|
|
110
110
|
- lib/stationery/layout/paginator.rb
|
|
111
111
|
- lib/stationery/layout/positioned.rb
|
|
112
112
|
- lib/stationery/layout/row.rb
|
|
113
|
+
- lib/stationery/layout/stack.rb
|
|
113
114
|
- lib/stationery/layout/svg.rb
|
|
114
115
|
- lib/stationery/layout/table.rb
|
|
115
116
|
- lib/stationery/layout/table/cell.rb
|
|
@@ -154,6 +155,7 @@ files:
|
|
|
154
155
|
- lib/stationery/rich/nodes.rb
|
|
155
156
|
- lib/stationery/rich/renderer.rb
|
|
156
157
|
- lib/stationery/rich/renderer/inlines.rb
|
|
158
|
+
- lib/stationery/rich/renderer/links.rb
|
|
157
159
|
- lib/stationery/rich/styles.rb
|
|
158
160
|
- lib/stationery/rspec.rb
|
|
159
161
|
- lib/stationery/structure.rb
|