stationery 0.2.0 → 0.3.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 +21 -1
- data/README.md +47 -10
- data/lib/stationery/builder.rb +2 -1
- data/lib/stationery/canvas/text.rb +5 -4
- data/lib/stationery/canvas.rb +13 -0
- data/lib/stationery/document.rb +9 -2
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/fonts/font.rb +36 -20
- data/lib/stationery/fonts/font_book.rb +3 -0
- data/lib/stationery/fonts/glyph_run.rb +4 -3
- data/lib/stationery/fonts/gpos.rb +11 -9
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/to_unicode.rb +1 -1
- data/lib/stationery/fonts/true_type.rb +36 -12
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/layout/box.rb +23 -6
- data/lib/stationery/layout/row.rb +1 -1
- data/lib/stationery/layout/svg.rb +5 -2
- data/lib/stationery/layout/text.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +3 -2
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/serializer.rb +17 -9
- data/lib/stationery/pdf/writer.rb +24 -5
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +48 -18
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +24 -4
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/style.rb +37 -20
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +11 -0
- data/lib/stationery/text/paragraph.rb +1 -0
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +2 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +16 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9ff43b2013eb330ebc103525c440bb5e4cfdaf0d05364baf47fd329c48ad11a
|
|
4
|
+
data.tar.gz: 718ede92892d3c7c0cdc556708880a2ba53f559611b872cfcb8ac946adb59908
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3a4a85b185b0f06e195a15f81773c86d79086e32e923b1c21c2d39ee528375fd5b75be1581fc3d8180e530071d653939674804c973896d0edb3b57fffbde9fa
|
|
7
|
+
data.tar.gz: fd368b63884a129cd0e6f03507da714891c79b81b6a1aeadde9cfeafc4987824d7cd525c2f86aa3eba397f432a14516429bc697b61177056a790a602d85369ae
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.3.0 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
The Limitations page, shortened: TrueType collections, WOFF, ligatures, splittable
|
|
6
|
+
`min_height:` boxes, SVG gradients, text and stylesheets, and encryption.
|
|
7
|
+
|
|
8
|
+
- SVG: `linearGradient` and `radialGradient` fills (`fill="url(#id)"`, also in `style`), with stops, `href`/`xlink:href` inheritance, `objectBoundingBox` and `userSpaceOnUse` units and `gradientTransform`, drawn as PDF axial/radial shadings clipped to the shape. Approximations: `reflect`/`repeat` spreads are drawn as `pad` (reported in `unsupported`), stop opacity is the first stop's for the whole gradient, and a gradient stroke is drawn in the gradient's middle colour. A reference to a missing gradient paints its fallback colour or nothing and is reported as `url(#id)`. `Canvas#shade` paints a shading dictionary inside a path.
|
|
9
|
+
- SVG: `text` and `tspan` (`x`/`y`/`dx`/`dy`, first value each; `font-family`, first family the font book knows — registered, bundled or an installed pack — else the document's default; `font-size`, `font-weight`, `font-style`, `text-anchor`, `fill`, `opacity`, transforms), drawn through the document's fonts so they subset and extract like any text. Whitespace collapses as in browsers. Glyphs stay upright: a transform moves the baseline origin and scales the size uniformly, so rotated or skewed text is approximated; `dominant-baseline` is ignored and a gradient fill uses its middle colour. `Layout::Svg` takes `context:`, `SVG::Document#draw` takes `book:` and `family:`, and `FontBook#known?` tells whether a family resolves without substitution.
|
|
10
|
+
- SVG: `<style>` stylesheets (plain or CDATA) as Illustrator and Inkscape export them. Selectors: element, `.class` (`.a.b`), `#id`, `element.class`, comma lists and `*`; rules with combinators (`g path`, `a > b`, …) are ignored and reported once as `style selectors: …`. Cascade: presentation attributes, then rules by specificity (id > class > element, later wins on ties), then inline `style`. Rules style shapes, text (`font-*`) and gradient stops (`stop-color`, `stop-opacity`); `display: none` skips an element and its children, `visibility: hidden` skips it (a `visible` child draws). `class` takes several classes.
|
|
11
|
+
|
|
12
|
+
- Encryption with the standard security handler: `to_pdf(encrypt: { owner_password:, user_password:, permissions:, algorithm: })` or `encrypt …` at class level (`to_pdf(encrypt: nil)` opts out). AES-256 (R6, default), AES-128 (R4) and RC4-128 (R3); every string and stream, document info included, is encrypted.
|
|
13
|
+
|
|
14
|
+
- Standard ligatures from the font's GSUB `liga` feature (LigatureSubst lookups, also behind Extension lookups), **on by default**: "office" in Open Sans draws the ffi ligature, so widths of affected words change slightly. `ligatures: false` on `text`/`text_style` or in `default_text` opts out; any `letter_spacing` turns them off. Text extraction and copy still yield the source characters (ToUnicode maps a ligature glyph to all of them). Fonts without `liga` ligatures, such as the bundled Inter, are unaffected.
|
|
15
|
+
|
|
16
|
+
### Fonts
|
|
17
|
+
|
|
18
|
+
- TrueType collections (`.ttc`): `font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"` picks a face by a `#N` suffix (face 0 without one); each face is parsed, cached, subset and embedded on its own. `TrueType.new(data, index:)`, `TrueType.collection?` and `TrueType.faces`; an index out of range raises `ArgumentError` naming the face count.
|
|
19
|
+
- WOFF 1.0 web fonts (`.woff`): `font_family "Web", regular: "Brand.woff"`. Tables are inflated with zlib into an in-memory sfnt (`Fonts::WOFF.unpack`), then measured, subset and embedded like the `.ttf`. WOFF2 is still rejected: it needs Brotli, so convert to `.ttf` or `.woff`.
|
|
20
|
+
|
|
21
|
+
- `box(min_height:)` and `column(min_height:)`: a height floor that, unlike `height:`, still splits across pages. The first fragment keeps as much of the floor as the page holds and the next carries the rest, so a row of equal-height cards or an empty signature area can span a page break. Passing both `height:` and `min_height:` raises `ArgumentError`.
|
|
22
|
+
|
|
23
|
+
## 0.2.0 (2026-09-27)
|
|
4
24
|
|
|
5
25
|
Everything from the three planned milestones ("works out of the box", "typography and layout",
|
|
6
26
|
"documents, Rails and testing") shipped together.
|
data/README.md
CHANGED
|
@@ -80,11 +80,11 @@ 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:, 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. `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). |
|
|
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. |
|
|
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). |
|
|
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
86
|
| `image(path_or_io, width:, height:, fit:, align:)` | JPEG or PNG, aspect preserved. |
|
|
87
|
-
| `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. |
|
|
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
89
|
| `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
90
|
| `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. |
|
|
@@ -98,7 +98,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
98
98
|
| `markdown(source, styles:, gap:, images:, base_path:, bookmarks:)` | The same from CommonMark (plus GFM tables and strikethrough). |
|
|
99
99
|
|
|
100
100
|
Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
|
|
101
|
-
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
101
|
+
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
102
102
|
`align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
|
|
103
103
|
ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
|
|
104
104
|
Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
|
|
@@ -228,6 +228,27 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
|
|
|
228
228
|
(with `#warnings`) instead of writing a PDF that produced any; `to_pdf(strict: false)` opts one
|
|
229
229
|
render out again.
|
|
230
230
|
|
|
231
|
+
### Encryption
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
class InvoicePdf < Stationery::Document
|
|
235
|
+
encrypt owner_password: "s3cret", permissions: [:print] # every render
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
InvoicePdf.new(invoice).to_pdf(encrypt: { user_password: "1234", owner_password: "s3cret",
|
|
239
|
+
permissions: %i[print copy] }) # override for one render
|
|
240
|
+
InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- `owner_password:` is required (`ArgumentError` when missing or empty); it opens the file with every
|
|
244
|
+
right. `user_password:` defaults to `""`: the file opens without a prompt, but viewers enforce the
|
|
245
|
+
permissions.
|
|
246
|
+
- `permissions:` is a subset of `%i[print modify copy annotate fill_forms extract_accessible assemble
|
|
247
|
+
print_high]` (default: all).
|
|
248
|
+
- `algorithm:` picks the standard security handler. `:aes_256` (default, PDF 2.0 / Acrobat X+);
|
|
249
|
+
`:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
|
|
250
|
+
- Every string and stream is encrypted, the document info included.
|
|
251
|
+
|
|
231
252
|
### Debugging
|
|
232
253
|
|
|
233
254
|
`to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
|
|
@@ -317,7 +338,14 @@ already present are kept unless `--force`. `--from` takes a directory or a
|
|
|
317
338
|
## Fonts and images
|
|
318
339
|
|
|
319
340
|
Fonts are TrueType (`.ttf`) or OpenType/CFF (`.otf`, name-keyed or
|
|
320
|
-
CID-keyed) files
|
|
341
|
+
CID-keyed) files, WOFF 1.0 web fonts (`.woff`, unwrapped in memory), or
|
|
342
|
+
faces of a TrueType collection (`.ttc`): a `#N` suffix
|
|
343
|
+
on the path picks face N, counted from 0 (face 0 without a suffix):
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"
|
|
347
|
+
```
|
|
348
|
+
Only the glyphs a document uses are embedded (a CFF font
|
|
321
349
|
keeps its glyph numbering and subroutines; unused glyphs are blanked), with a
|
|
322
350
|
ToUnicode map so text copies and searches correctly. A style without its own
|
|
323
351
|
file (bold, italic) is synthesised.
|
|
@@ -379,6 +407,15 @@ turns it off). Pairs that straddle a style or
|
|
|
379
407
|
font change are not kerned. Kerning only tightens in practice, so a kerned
|
|
380
408
|
line is never wider than the same line unkerned.
|
|
381
409
|
|
|
410
|
+
Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature
|
|
411
|
+
(LigatureSubst lookups, also behind Extension lookups) and are on by default;
|
|
412
|
+
`ligatures: false` on an element or in `default_text` turns them off. Only
|
|
413
|
+
`liga` applies, not `clig` or `dlig`. Ligatures form within a run of one
|
|
414
|
+
style and font, never across a line break, and letter spacing turns them off.
|
|
415
|
+
The PDF's ToUnicode map sends a ligature glyph back to all of its
|
|
416
|
+
characters, so copied and extracted text still reads "office". Fonts
|
|
417
|
+
without a `liga` feature (such as the bundled Inter) are unaffected.
|
|
418
|
+
|
|
382
419
|
Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
|
|
383
420
|
mask). Parsed fonts and images are cached per process.
|
|
384
421
|
|
|
@@ -466,11 +503,11 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
|
|
|
466
503
|
|
|
467
504
|
## Limitations
|
|
468
505
|
|
|
469
|
-
No
|
|
470
|
-
|
|
471
|
-
(no
|
|
472
|
-
|
|
473
|
-
|
|
506
|
+
No variable fonts (including CFF2) or
|
|
507
|
+
WOFF2 (it needs Brotli; convert to `.ttf` or `.woff`); SVG covers the shapes icon sets use
|
|
508
|
+
(no patterns or masks); no forms or
|
|
509
|
+
tagged PDF. A box with a fixed `height:` never splits (use
|
|
510
|
+
`min_height:` for a floor that can); a row splits only when every column can.
|
|
474
511
|
|
|
475
512
|
## License
|
|
476
513
|
|
data/lib/stationery/builder.rb
CHANGED
|
@@ -4,7 +4,8 @@ module Stationery
|
|
|
4
4
|
# Collects the nodes a component tree describes. Holds the container stack
|
|
5
5
|
# (where the next node goes) and the text defaults in effect.
|
|
6
6
|
class Builder
|
|
7
|
-
STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning
|
|
7
|
+
STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning
|
|
8
|
+
ligatures].freeze
|
|
8
9
|
|
|
9
10
|
# Collects a row's columns; anything that is not already a column box is
|
|
10
11
|
# wrapped in one.
|
|
@@ -7,15 +7,16 @@ module Stationery
|
|
|
7
7
|
BOLD_STROKE = 0.03
|
|
8
8
|
|
|
9
9
|
# Draws `string` with its baseline at (x, y) in top-left coordinates and
|
|
10
|
-
# returns its advance width.
|
|
10
|
+
# returns its advance width. Standard ligatures form unless `ligatures:`
|
|
11
|
+
# is false or letter spacing is set.
|
|
11
12
|
def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
|
|
12
13
|
synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
|
|
13
|
-
kerning: false, word_spacing: 0)
|
|
14
|
+
kerning: false, ligatures: true, word_spacing: 0)
|
|
14
15
|
return 0 if string.empty?
|
|
15
16
|
|
|
16
17
|
color = Color.parse(color)
|
|
17
|
-
run = font.glyph_run(string, kerning:)
|
|
18
|
-
run = run.with_word_spacing(
|
|
18
|
+
run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?)
|
|
19
|
+
run = run.with_word_spacing(word_spacing, size) unless word_spacing.zero?
|
|
19
20
|
width = run.width(size, letter_spacing:)
|
|
20
21
|
graphics(opacity:) do |ops|
|
|
21
22
|
ops << color.fill
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -58,6 +58,19 @@ module Stationery
|
|
|
58
58
|
shape(fill:, stroke:, line_width:, cap:, join:, dash:, even_odd:, opacity:, transform:, &)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Paints `shading` inside the path the block traces. `matrix` maps the
|
|
62
|
+
# shading's coordinates into top-left page space.
|
|
63
|
+
def shade(shading, matrix:, transform: nil, even_odd: false, opacity: nil)
|
|
64
|
+
path = Path.new(self, transform:)
|
|
65
|
+
yield path
|
|
66
|
+
name = @page.use(:Shading, @resources.shading(shading))
|
|
67
|
+
a, b, c, d, e, f = matrix
|
|
68
|
+
graphics(opacity:) do |ops|
|
|
69
|
+
ops << path.to_s << (even_odd ? "W* n" : "W n")
|
|
70
|
+
ops << "#{[a, -b, c, -d, e, @page.height - f].map { |v| num(v) }.join(" ")} cm" << "/#{name} sh"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
61
74
|
def image(image, x:, y:, width:, height:, opacity: nil)
|
|
62
75
|
name = @page.use(:XObject, @resources.image(image))
|
|
63
76
|
graphics(opacity:) do |ops|
|
data/lib/stationery/document.rb
CHANGED
|
@@ -55,6 +55,12 @@ module Stationery
|
|
|
55
55
|
config[:strict] = value
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Encrypts every render with the standard security handler; see
|
|
59
|
+
# PDF::Encryption::StandardSecurity for the options.
|
|
60
|
+
def encrypt(**)
|
|
61
|
+
config[:encrypt] = PDF::Encryption::StandardSecurity.options(**)
|
|
62
|
+
end
|
|
63
|
+
|
|
58
64
|
# Runs after pagination on every page. `layer: :background` paints under
|
|
59
65
|
# the page's content.
|
|
60
66
|
def page_template(layer: :foreground, &block)
|
|
@@ -79,7 +85,8 @@ module Stationery
|
|
|
79
85
|
def page_options = self.class.config[:page]
|
|
80
86
|
def metadata = self.class.config[:metadata]
|
|
81
87
|
|
|
82
|
-
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false)
|
|
88
|
+
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt])
|
|
89
|
+
encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
|
|
83
90
|
warnings = Warnings.new
|
|
84
91
|
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
|
|
85
92
|
call(builder = Builder.new(book:, text: self.class.config[:text]))
|
|
@@ -92,7 +99,7 @@ module Stationery
|
|
|
92
99
|
@warnings = warnings
|
|
93
100
|
raise WarningsError, warnings if strict && warnings.any?
|
|
94
101
|
|
|
95
|
-
write(PDF::Assembler.new(pages:, resources:, info:, outline:).render, target)
|
|
102
|
+
write(PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:).render, target)
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
# Used by page templates to build nodes into their own root.
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Stationery
|
|
|
73
73
|
if document.unsupported.any?
|
|
74
74
|
@_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
|
|
75
75
|
end
|
|
76
|
-
node = Layout::Svg.new(document, width:, height:, color:)
|
|
76
|
+
node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context)
|
|
77
77
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
78
78
|
end
|
|
79
79
|
|
|
@@ -17,11 +17,11 @@ module Stationery
|
|
|
17
17
|
def initialize(ttf)
|
|
18
18
|
@ttf = ttf
|
|
19
19
|
@used = {}
|
|
20
|
-
@widths = {}
|
|
21
20
|
@pairs = {}
|
|
22
21
|
@glyphs = {}
|
|
23
|
-
@
|
|
24
|
-
@
|
|
22
|
+
@shapes = { true => {}, false => {} }
|
|
23
|
+
@advances = { true => {}, false => {} }
|
|
24
|
+
@kerns = { true => {}, false => {} }
|
|
25
25
|
@cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -29,11 +29,14 @@ module Stationery
|
|
|
29
29
|
|
|
30
30
|
# Advances and kerning are remembered per string, so measuring the same
|
|
31
31
|
# word again (wrapping, then laying out the line) allocates nothing.
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
# Letter spacing is added per glyph, so a ligature counts once; any
|
|
33
|
+
# letter spacing turns ligatures off, as it does when drawing.
|
|
34
|
+
def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true)
|
|
35
|
+
ligatures &&= letter_spacing.zero?
|
|
36
|
+
width = scale(advance_units(text, ligatures), size) + (letter_spacing * shape(text, ligatures).first.size)
|
|
34
37
|
return width unless kerning
|
|
35
38
|
|
|
36
|
-
width + (kerning_units(text) * size / 1000.0)
|
|
39
|
+
width + (kerning_units(text, ligatures) * size / 1000.0)
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def ascender(size) = scale(@ttf.ascender, size)
|
|
@@ -53,15 +56,13 @@ module Stationery
|
|
|
53
56
|
|
|
54
57
|
def encode(text) = glyph_run(text).gids.map { |gid| code(gid) }.pack("n*")
|
|
55
58
|
|
|
56
|
-
# `
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
gid
|
|
62
|
-
end
|
|
59
|
+
# `ligatures:` substitutes the font's standard ligatures; `kerning:`
|
|
60
|
+
# then fills the adjustments with pair kerning between the glyphs.
|
|
61
|
+
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] }
|
|
63
64
|
adjust = gids.each_with_index.map { |gid, i| kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0 }
|
|
64
|
-
GlyphRun.new(font: self, gids:, adjust:)
|
|
65
|
+
GlyphRun.new(font: self, gids:, adjust:, chars:)
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
def used?
|
|
@@ -74,7 +75,8 @@ module Stationery
|
|
|
74
75
|
@cid_keyed ? @ttf.cff.cid_for(gid) : gid
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
# { code =>
|
|
78
|
+
# { code => text } for every glyph drawn so far; a ligature's text is
|
|
79
|
+
# every character it stands for.
|
|
78
80
|
def used_codes
|
|
79
81
|
@used.to_h { |gid, char| [code(gid), char] }
|
|
80
82
|
end
|
|
@@ -91,13 +93,27 @@ module Stationery
|
|
|
91
93
|
|
|
92
94
|
private
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
# [gids, source text of each glyph], remembered per string.
|
|
97
|
+
def shape(text, ligatures)
|
|
98
|
+
@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
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ligate(gids, text)
|
|
105
|
+
start = 0
|
|
106
|
+
glyphs = @ttf.ligatures.substitute(gids)
|
|
107
|
+
chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
|
|
108
|
+
[glyphs.map(&:first), chars].each(&:freeze).freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def advance_units(text, ligatures)
|
|
112
|
+
@advances[ligatures][text] ||= shape(text, ligatures).first.sum { |gid| @ttf.advance(gid) }
|
|
96
113
|
end
|
|
97
114
|
|
|
98
|
-
def kerning_units(text)
|
|
99
|
-
@kerns[text] ||= text.
|
|
100
|
-
.each_cons(2).sum { |left, right| pair(left, right) }
|
|
115
|
+
def kerning_units(text, ligatures)
|
|
116
|
+
@kerns[ligatures][text] ||= shape(text, ligatures).first.each_cons(2).sum { |left, right| pair(left, right) }
|
|
101
117
|
end
|
|
102
118
|
|
|
103
119
|
# Kerning between two glyphs in thousandths of an em.
|
|
@@ -34,6 +34,9 @@ module Stationery
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Whether `name` is registered, bundled or an installed pack.
|
|
38
|
+
def known?(name) = !(@families[name.to_s] || bundled(name) || pack(name)).nil?
|
|
39
|
+
|
|
37
40
|
# The runs split so every character is drawn by a font that has it. Runs
|
|
38
41
|
# this book already split come back as they are.
|
|
39
42
|
def fallback(runs)
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
5
|
# Glyph ids for one run of text in one font, with an extra advance after
|
|
6
|
-
# each glyph in thousandths of the font size (positive widens)
|
|
6
|
+
# each glyph in thousandths of the font size (positive widens) and the
|
|
7
|
+
# source text of each glyph (several characters for a ligature). All-zero
|
|
7
8
|
# adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
|
|
8
9
|
# are written as the font's character codes (see Font#code).
|
|
9
|
-
GlyphRun = Data.define(:font, :gids, :adjust) do
|
|
10
|
+
GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
|
|
10
11
|
def width(size, letter_spacing: 0)
|
|
11
12
|
units = gids.sum { |gid| font.ttf.advance(gid) }
|
|
12
13
|
(units * size / font.ttf.units_per_em.to_f) + (adjust.sum * size / 1000.0) + (letter_spacing * gids.size)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
def with_word_spacing(
|
|
16
|
+
def with_word_spacing(extra_points, size)
|
|
16
17
|
extra = extra_points * 1000.0 / size
|
|
17
18
|
with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
|
|
18
19
|
end
|
|
@@ -15,19 +15,21 @@ module Stationery
|
|
|
15
15
|
base = ttf.table_offset("GPOS")
|
|
16
16
|
return unless base && ttf.u16(base) == 1
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
list = base + ttf.u16(base + 8)
|
|
22
|
-
new(indices.map { |i| lookup(ttf, list + ttf.u16(list + 2 + (i * 2))) })
|
|
18
|
+
offsets = feature_lookups(ttf, base, "kern")
|
|
19
|
+
new(offsets.map { |offset| lookup(ttf, offset) }) if offsets.any?
|
|
23
20
|
end
|
|
24
21
|
|
|
25
|
-
|
|
22
|
+
# Offsets of the lookups every `tag` feature record of the GSUB or GPOS
|
|
23
|
+
# table at `base` names, in LookupList order.
|
|
24
|
+
def self.feature_lookups(ttf, base, tag)
|
|
25
|
+
list = base + ttf.u16(base + 6)
|
|
26
26
|
records = Array.new(ttf.u16(list)) { |i| list + 2 + (i * 6) }
|
|
27
|
-
records.select { |record| ttf.data.byteslice(record, 4) ==
|
|
27
|
+
indices = records.select { |record| ttf.data.byteslice(record, 4) == tag }.flat_map do |record|
|
|
28
28
|
feature = list + ttf.u16(record + 4)
|
|
29
29
|
Array.new(ttf.u16(feature + 2)) { |j| ttf.u16(feature + 4 + (j * 2)) }
|
|
30
|
-
end
|
|
30
|
+
end
|
|
31
|
+
lookups = base + ttf.u16(base + 8)
|
|
32
|
+
indices.uniq.sort.map { |i| lookups + ttf.u16(lookups + 2 + (i * 2)) }
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# A lookup's PairPos subtables; other lookup types have none.
|
|
@@ -42,7 +44,7 @@ module Stationery
|
|
|
42
44
|
end
|
|
43
45
|
end.freeze
|
|
44
46
|
end
|
|
45
|
-
private_class_method :
|
|
47
|
+
private_class_method :lookup
|
|
46
48
|
|
|
47
49
|
def initialize(lookups)
|
|
48
50
|
@lookups = lookups.freeze
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gsub
|
|
6
|
+
# LigatureSubst format 1 (GSUB lookup type 4): for each first glyph, the
|
|
7
|
+
# ligatures starting with it, longest first.
|
|
8
|
+
class LigatureSubst
|
|
9
|
+
def self.read(ttf, offset)
|
|
10
|
+
return unless ttf.u16(offset) == 1
|
|
11
|
+
|
|
12
|
+
sets = Gpos::Coverage.read(ttf, offset + ttf.u16(offset + 2)).each_with_index.to_h do |first, i|
|
|
13
|
+
set = offset + ttf.u16(offset + 6 + (i * 2))
|
|
14
|
+
[first, ligatures(ttf, set).sort_by.with_index { |(rest, _), j| [-rest.size, j] }.freeze]
|
|
15
|
+
end
|
|
16
|
+
new(sets)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# [[component gids after the first, ligature gid], ...]
|
|
20
|
+
def self.ligatures(ttf, set)
|
|
21
|
+
Array.new(ttf.u16(set)) do |i|
|
|
22
|
+
ligature = set + ttf.u16(set + 2 + (i * 2))
|
|
23
|
+
count = ttf.u16(ligature + 2)
|
|
24
|
+
[ttf.data.byteslice(ligature + 4, (count - 1) * 2).unpack("n*").freeze, ttf.u16(ligature)]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_class_method :ligatures
|
|
28
|
+
|
|
29
|
+
def initialize(sets)
|
|
30
|
+
@sets = sets.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# [ligature gid, glyphs consumed] for the longest ligature at `index`, or nil.
|
|
35
|
+
def match(gids, index)
|
|
36
|
+
@sets[gids[index]]&.each do |components, ligature|
|
|
37
|
+
return [ligature, components.size + 1] if gids[index + 1, components.size] == components
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Standard ligatures from the GSUB `liga` feature: LigatureSubst lookups
|
|
6
|
+
# (format 1), directly or behind Extension lookups. Only `liga` applies;
|
|
7
|
+
# contextual (`clig`) and discretionary (`dlig`) ligatures are left out.
|
|
8
|
+
# Scripts, languages and lookup flags are not distinguished.
|
|
9
|
+
class Gsub
|
|
10
|
+
LIGATURE = 4
|
|
11
|
+
EXTENSION = 7
|
|
12
|
+
|
|
13
|
+
# nil when the font has no GSUB table or no `liga` ligatures.
|
|
14
|
+
def self.parse(ttf)
|
|
15
|
+
base = ttf.table_offset("GSUB")
|
|
16
|
+
return unless base && ttf.u16(base) == 1
|
|
17
|
+
|
|
18
|
+
lookups = Gpos.feature_lookups(ttf, base, "liga").map { |offset| lookup(ttf, offset) }.reject(&:empty?)
|
|
19
|
+
new(lookups) if lookups.any?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A lookup's LigatureSubst subtables; other lookup types have none.
|
|
23
|
+
def self.lookup(ttf, offset)
|
|
24
|
+
type = ttf.u16(offset)
|
|
25
|
+
subtables = Array.new(ttf.u16(offset + 4)) { |i| offset + ttf.u16(offset + 6 + (i * 2)) }
|
|
26
|
+
subtables.filter_map do |subtable|
|
|
27
|
+
if type == LIGATURE
|
|
28
|
+
LigatureSubst.read(ttf, subtable)
|
|
29
|
+
elsif type == EXTENSION && ttf.u16(subtable + 2) == LIGATURE
|
|
30
|
+
LigatureSubst.read(ttf, subtable + ttf.u32(subtable + 4))
|
|
31
|
+
end
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
private_class_method :lookup
|
|
35
|
+
|
|
36
|
+
def initialize(lookups)
|
|
37
|
+
@lookups = lookups.freeze
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# [[gid, number of source glyphs it stands for], ...]. Lookups apply in
|
|
42
|
+
# order; within one, the first subtable matching at a position wins and
|
|
43
|
+
# its glyph is not matched again by that lookup.
|
|
44
|
+
def substitute(gids)
|
|
45
|
+
@lookups.reduce(gids.map { |gid| [gid, 1] }) { |glyphs, subtables| apply(subtables, glyphs) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def apply(subtables, glyphs)
|
|
51
|
+
ids = glyphs.map(&:first)
|
|
52
|
+
result = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < glyphs.size
|
|
55
|
+
match = nil
|
|
56
|
+
subtables.find { |subtable| match = subtable.match(ids, i) }
|
|
57
|
+
length = match ? match.last : 1
|
|
58
|
+
result << [match ? match.first : ids[i], glyphs[i, length].sum(&:last)]
|
|
59
|
+
i += length
|
|
60
|
+
end
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Chooses where a font's ligatures come from. Every source answers
|
|
6
|
+
# `substitute(gids)` with [[gid, source glyph count], ...].
|
|
7
|
+
module Ligatures
|
|
8
|
+
# For fonts without ligatures: every glyph stands for itself.
|
|
9
|
+
module NONE
|
|
10
|
+
def self.substitute(gids) = gids.map { |gid| [gid, 1] }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.for(ttf) = Gsub.parse(ttf) || NONE
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
|
-
# Process-wide cache of parsed TrueType files, keyed by path and
|
|
6
|
-
# edited font is re-read. Parsing is the expensive part; the
|
|
7
|
-
# Font objects that track used glyphs are cheap wrappers
|
|
5
|
+
# Process-wide cache of parsed TrueType files, keyed by path, face and
|
|
6
|
+
# mtime so an edited font is re-read. Parsing is the expensive part; the
|
|
7
|
+
# per-document Font objects that track used glyphs are cheap wrappers
|
|
8
|
+
# around a parse. A `#N` suffix on the path picks face N of a collection.
|
|
8
9
|
module Registry
|
|
9
10
|
SIZE = 16
|
|
11
|
+
FACE = /\A(.+)#(\d+)\z/
|
|
10
12
|
@cache = {}
|
|
11
13
|
@mutex = Mutex.new
|
|
12
14
|
|
|
13
15
|
class << self
|
|
14
16
|
def load(path)
|
|
15
|
-
path =
|
|
17
|
+
path, index = split(path.to_s)
|
|
18
|
+
path = File.expand_path(path)
|
|
16
19
|
raise UnsupportedFont, "font file not found: #{path}" unless File.file?(path)
|
|
17
20
|
|
|
18
21
|
mtime = File.mtime(path)
|
|
19
|
-
@mutex.synchronize { fetch(path, mtime) }
|
|
22
|
+
@mutex.synchronize { fetch(path, index, mtime) }
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def clear
|
|
@@ -25,10 +28,16 @@ module Stationery
|
|
|
25
28
|
|
|
26
29
|
private
|
|
27
30
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
def split(path)
|
|
32
|
+
match = FACE.match(path)
|
|
33
|
+
match ? [match[1], match[2].to_i] : [path, 0]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch(path, index, mtime)
|
|
37
|
+
key = [path, index]
|
|
38
|
+
cached_mtime, ttf = @cache.delete(key)
|
|
39
|
+
ttf = TrueType.new(File.binread(path), index:) unless cached_mtime == mtime
|
|
40
|
+
@cache[key] = [mtime, ttf]
|
|
32
41
|
@cache.shift while @cache.size > SIZE
|
|
33
42
|
ttf
|
|
34
43
|
end
|
|
@@ -7,7 +7,7 @@ module Stationery
|
|
|
7
7
|
module ToUnicode
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
# `chars` is { code =>
|
|
10
|
+
# `chars` is { code => text }; a ligature maps to several characters.
|
|
11
11
|
def cmap(chars)
|
|
12
12
|
mappings = chars.sort.map do |code, char|
|
|
13
13
|
format("<%<code>04X> <%<utf16>s>", code:, utf16: char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)
|