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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc2011ee51b715eb946943e60d0512157f2215acd871133433c6edf4f81bb9a9
4
- data.tar.gz: a5a9c654b4c415ab77ba005eb228ba67fae05f87af1adeb39871ccb3353a00a3
3
+ metadata.gz: dab8be06e23e27d74ddd2ec627bc34923a79a9c0cb5fe40da55f43bdcb81e337
4
+ data.tar.gz: a7f90eadef2004318933e8a65cd1f86841c5b9bfc4cba8ff6191b2f96946b8e5
5
5
  SHA512:
6
- metadata.gz: 239ad178900f1cce55589e1e6882fda2e713631df0a0d732945eefd0d8df762e9baa0a44efde7ea13fa643e6ee0dd038594886cd09cc14aac2cb0486ef88f05b
7
- data.tar.gz: 0ac757cdf6a15a654c83b26fa80c5be99e20e9e28e09653c31c299627a5c46c5cfb3965cfa58e58c2c6ade241c3b86f07fbe37a761ad2738e09207ce0711c200
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 fillable form; `bundle exec rake examples`
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
- `li` (text style) and `img` (`max_width:`).
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. A glyph no font has is drawn as the family's
485
- `.notdef` and reported as a `Warnings::MissingGlyph` counting each drawn
486
- occurrence (so `strict` raises on it). Fallback covers every text element,
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. For anything else, `Stationery::Testing::Inspector.new(subject)`
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
@@ -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
 
@@ -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
- def html(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false)
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
@@ -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 combining marks carry the
10
- # family of the character before them (or after them at the start of a
11
- # run) so words are not chopped and marks stay with their base.
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 = /[\s‌‍︀-️\p{M}]/
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 { |gid, i| kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0 }
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], remembered per string.
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| @ttf.glyph_id(char.ord) }
100
- ligatures ? ligate(gids, text) : [gids, text.chars].each(&:freeze).freeze
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].each(&:freeze).freeze
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] ||= shape(text, ligatures).first.sum { |gid| @ttf.advance(gid) }
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? = @height.nil? && @overflow == :visible && @content.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.structure(@tag) do
87
- canvas.structure(@link_tag) do
88
- paint_background(canvas, x, y, width, height)
89
- paint_border(canvas, x, y, width, height)
90
- paint_content(canvas, x, y, width, height, valign || @valign)
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
- if @overflow == :visible
194
- paint_inner.call
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]) { canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity) }
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
@@ -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)
@@ -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
- document = klass.new.render(pdf, request.query_parameters.except("debug"))
23
- send_data document.to_pdf(**debug_option(document)), type: "application/pdf", disposition: "inline"
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.marks.flat_map { |mark, value| steps(mark, value) }, inline.text)
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
@@ -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 = objects.deref!(objects.trailer[:Root])[: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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -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.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