stationery 0.6.0 → 0.7.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 +10 -0
- data/README.md +72 -15
- data/lib/stationery/builder.rb +5 -3
- data/lib/stationery/canvas/text.rb +4 -3
- data/lib/stationery/canvas.rb +5 -2
- data/lib/stationery/document.rb +26 -9
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/fonts/fallback.rb +3 -3
- data/lib/stationery/fonts/font.rb +39 -21
- data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
- data/lib/stationery/fonts/gsub.rb +65 -20
- data/lib/stationery/fonts/ligatures.rb +6 -4
- data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
- data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
- data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
- data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
- data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
- data/lib/stationery/hyphenation.rb +132 -0
- data/lib/stationery/images/png.rb +66 -0
- data/lib/stationery/images/resampled.rb +80 -0
- data/lib/stationery/layout/image.rb +28 -2
- data/lib/stationery/layout/paginator.rb +1 -1
- data/lib/stationery/layout/text.rb +2 -2
- data/lib/stationery/minitest.rb +5 -0
- data/lib/stationery/page_templates.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +6 -2
- data/lib/stationery/pdf/page_labels.rb +71 -0
- data/lib/stationery/testing/inspector.rb +13 -0
- data/lib/stationery/testing/matchers.rb +10 -0
- data/lib/stationery/text/entities.rb +14 -4
- data/lib/stationery/text/paragraph.rb +1 -1
- data/lib/stationery/text/style.rb +14 -3
- data/lib/stationery/text/wrapper.rb +73 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +6 -0
- data/lib/stationery.rb +4 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f9e83f23437589a026e0a9a2fde163a39a2c4151c434afc5bdbea38a98891dd
|
|
4
|
+
data.tar.gz: 67e42695ae205e05049441490b89b908f6468ba94e00c095c1de8eaeae986d65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40756fe30e4c2d400fab7402e490609cf313e8ae06c5317489f6b0337f96fc5ad00d8f2c8b05b348812955b109cc0f9685f9c1668b8f307d1fd16ac9492facad
|
|
7
|
+
data.tar.gz: 1a7938f3b608b68df057d3385d5824dc6a57338978a76ad39bed42701838ee2a408db1c2d30daccf3a834b03bea9f6d1441ef6405691dff0090611d26cbe05a0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 (2026-09-28)
|
|
4
|
+
|
|
5
|
+
Typography and images: hyphenation, OpenType features, oversized-image warnings, page labels.
|
|
6
|
+
|
|
7
|
+
- Hyphenation: `hyphenate: true | "en" | "de" | "sv"` on `text`, `text_style`, `default_text` and the rich `styles:` (`p`, `li`). Liang's algorithm over the TeX `hyph-utf8` patterns for English, German and Swedish (bundled with their licences), so a word that does not fit breaks at the longest hyphenation point that does, with the font's hyphen. A soft hyphen (U+00AD, `­` in HTML) is a break point in any language: never drawn or measured unless the line breaks there, and absent from extracted text. Off by default; unchanged output otherwise.
|
|
8
|
+
- OpenType features: `features: %i[smcp onum tnum ss01]` on the same entry points applies the font's GSUB single substitutions (formats 1 and 2, also behind Extension lookups) before ligatures; `Font#features` lists what a font offers, an absent feature is ignored. Substituted glyphs keep their source characters in ToUnicode. Contextual lookups (`calt`, `clig`, `frac`) stay unsupported and are listed as such.
|
|
9
|
+
- Oversized images: a bitmap drawn at more than twice `max_ppi` (300 by default; `images max_ppi: 220` per document, `image(max_ppi:)` per call, `nil` disables) reports `Warnings::OversizedImage` naming the source, pixel width, effective ppi and the limit, so `strict` catches a 1600 px photo drawn 160 pt wide. `downscale: true` resamples a PNG (grey, RGB, palette, alpha) to the limit before embedding; JPEG is never re-encoded and only warns.
|
|
10
|
+
- Page labels: `page_labels 1 => { style: :roman_lower }, 4 => { style: :decimal }, 12 => { style: :alpha, prefix: "Appendix " }` (or `to_pdf(page_labels:)`) writes the `/PageLabels` number tree; `Inspector#page_labels`, `have_page_labels` and `assert_page_labels` read it back.
|
|
11
|
+
- `text(…, markup: true)` decodes HTML character references (`&`, `'`, `'`, the named entities the HTML parser knows), so data escaped with `html_escape` before adding your own `<b>` tags renders as intended instead of showing `'`.
|
|
12
|
+
|
|
3
13
|
## 0.6.0 (2026-09-28)
|
|
4
14
|
|
|
5
15
|
Instrumentation, so a render shows up in AppSignal and friends, and a faster text pipeline.
|
data/README.md
CHANGED
|
@@ -70,7 +70,8 @@ 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, fillable form
|
|
73
|
+
`examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage and event flyer
|
|
74
|
+
([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
|
|
74
75
|
renders them all, or render one with `stationery render examples/report.rb`.
|
|
75
76
|
|
|
76
77
|
## Elements
|
|
@@ -78,12 +79,12 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
78
79
|
| Element | What it does |
|
|
79
80
|
|---|---|
|
|
80
81
|
| `text(string, **style)` | A paragraph. Plain strings are literal. |
|
|
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
|
+
| `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 once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `<b>` stays literal text. |
|
|
82
83
|
| `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
|
|
83
84
|
| `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
85
|
| `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
86
|
| `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:, 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
|
+
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:)` | 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. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG to that resolution instead. |
|
|
87
88
|
| `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
89
|
| `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
|
|
89
90
|
| `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. |
|
|
@@ -105,7 +106,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
105
106
|
| `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | The same from CommonMark (plus GFM tables and strikethrough). |
|
|
106
107
|
|
|
107
108
|
Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
|
|
108
|
-
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
109
|
+
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `features` (OpenType feature tags, e.g. `%i[smcp onum]`), `hyphenate` (`true` for English, or `"de"`, `"sv"`; default off), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
109
110
|
`align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
|
|
110
111
|
ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
|
|
111
112
|
Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
|
|
@@ -259,6 +260,10 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
|
|
|
259
260
|
fits a normal page but not the last one continues onto an extra page.
|
|
260
261
|
- With a header or footer, a `page_template`'s `page.content_box` excludes their space (it is the
|
|
261
262
|
body's area).
|
|
263
|
+
- `page_labels 1 => { style: :roman_lower }, 3 => { style: :decimal, start: 1, prefix: "A-" }` names
|
|
264
|
+
the pages the way viewers show them ("i", "ii", "A-1", …): a 1-based first page mapped to the range
|
|
265
|
+
that starts there, with `style:` `:decimal`, `:roman`, `:roman_lower`, `:alpha`, `:alpha_lower` or
|
|
266
|
+
`nil` (prefix only), `start:` and `prefix:`. `to_pdf(page_labels:)` overrides it for one render.
|
|
262
267
|
- Content taller than a page is placed anyway; `document.warnings` lists every overflow.
|
|
263
268
|
- After `to_pdf`, `document.warnings` is an Enumerable of everything the render noticed but did not
|
|
264
269
|
raise on, each with a `#message`: overflows, SVG elements that were skipped (`UnsupportedSvg`), and
|
|
@@ -521,16 +526,48 @@ line is never wider than the same line unkerned.
|
|
|
521
526
|
|
|
522
527
|
Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature
|
|
523
528
|
(LigatureSubst lookups, also behind Extension lookups) and are on by default;
|
|
524
|
-
`ligatures: false` on an element or in `default_text` turns them off.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
529
|
+
`ligatures: false` on an element or in `default_text` turns them off.
|
|
530
|
+
Ligatures form within a run of one style and font, never across a line
|
|
531
|
+
break, and letter spacing turns them off. The PDF's ToUnicode map sends a
|
|
532
|
+
ligature glyph back to all of its characters, so copied and extracted text
|
|
533
|
+
still reads "office". Fonts without a `liga` feature (such as the bundled
|
|
534
|
+
Inter) are unaffected.
|
|
535
|
+
|
|
536
|
+
Other OpenType features are opt-in per element, `text_style` or
|
|
537
|
+
`default_text`: `features: %i[smcp onum]` applies the font's small caps and
|
|
538
|
+
oldstyle figures, `tnum`/`pnum` pick tabular or proportional figures for
|
|
539
|
+
tables, `zero` a slashed zero, `dlig` discretionary ligatures, `ss01`… the
|
|
540
|
+
stylistic sets. Single (SingleSubst) and ligature (LigatureSubst) lookups
|
|
541
|
+
are applied in the font's own order; a feature the font lacks is ignored,
|
|
542
|
+
and `Font#features` lists what a font offers. Substituted glyphs keep
|
|
543
|
+
their source characters in ToUnicode, so "2026" in oldstyle figures still
|
|
544
|
+
extracts as "2026". Contextual features (`calt`, `clig`, `frac`) need
|
|
545
|
+
lookup types the reader does not implement and do nothing.
|
|
546
|
+
|
|
547
|
+
Hyphenation is off unless asked for: `hyphenate: "de"` on `text`, `text_style`,
|
|
548
|
+
`default_text` or an `html`/`markdown` style (`styles: { p: { hyphenate: "de" } }`)
|
|
549
|
+
breaks a word that does not fit at the longest point Liang's algorithm allows
|
|
550
|
+
over the bundled TeX patterns (`true` or `"en"` for American English, `"de"`
|
|
551
|
+
for German, `"sv"` for Swedish; `lib/stationery/hyphenation/patterns/LICENSES.md`
|
|
552
|
+
names their authors and licences), drawing a hyphen at the break. A soft hyphen
|
|
553
|
+
(U+00AD, `­` in HTML) names the break points of a word yourself and, as in
|
|
554
|
+
TeX, exempts that word from the patterns; it is never measured or drawn and
|
|
555
|
+
never reaches the PDF. `Stationery::Hyphenation.hyphenate("Silbentrennung", "de")`
|
|
556
|
+
answers `["Sil", "ben", "tren", "nung"]` for your own use.
|
|
530
557
|
|
|
531
558
|
Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
|
|
532
559
|
mask). Parsed fonts and images are cached per process.
|
|
533
560
|
|
|
561
|
+
A bitmap keeps its pixels, so a 1600 px photo drawn 160 pt wide ships all
|
|
562
|
+
1600 px at 720 ppi. An image drawn at more than twice the document's
|
|
563
|
+
`max_ppi` (300, or `images max_ppi: 220` at class level, `nil` to switch the
|
|
564
|
+
check off) is reported as a `Warnings::OversizedImage` naming the file, its
|
|
565
|
+
pixel width and the resolution it lands at. `images downscale: true` (or
|
|
566
|
+
`image(..., downscale: true)`) resamples a PNG to `max_ppi` at its drawn size
|
|
567
|
+
before embedding it, alpha included; a JPEG is embedded byte for byte, so
|
|
568
|
+
resize it before you embed it (an ActiveStorage variant per drawn size,
|
|
569
|
+
preprocessed, keeps a render to a download).
|
|
570
|
+
|
|
534
571
|
## Testing
|
|
535
572
|
|
|
536
573
|
`stationery/rspec` and `stationery/minitest` read a rendered PDF back for
|
|
@@ -560,6 +597,7 @@ RSpec.describe InvoicePdf do
|
|
|
560
597
|
it { is_expected.to have_image_count(1) }
|
|
561
598
|
it { is_expected.to have_no_warnings }
|
|
562
599
|
it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
|
|
600
|
+
it { is_expected.to have_page_labels(%w[i ii 1 2]) } # from `page_labels`
|
|
563
601
|
it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
|
|
564
602
|
it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
|
|
565
603
|
end
|
|
@@ -581,6 +619,7 @@ class InvoicePdfTest < Minitest::Test
|
|
|
581
619
|
assert_pdf_link pdf, /acme\.test/
|
|
582
620
|
assert_no_pdf_warnings pdf
|
|
583
621
|
assert_pdf_language pdf, "en"
|
|
622
|
+
assert_page_labels pdf, %w[i ii 1 2]
|
|
584
623
|
assert_tagged_content pdf
|
|
585
624
|
assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
|
|
586
625
|
end
|
|
@@ -592,7 +631,7 @@ The matcher names carry a `pdf_` prefix so they never clash with Capybara's
|
|
|
592
631
|
titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and inside
|
|
593
632
|
`all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
|
|
594
633
|
exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
|
|
595
|
-
`image_count`, `bookmarks`, `metadata`, `lang`, `warnings`, `tagged?`, `untagged_text` and
|
|
634
|
+
`image_count`, `bookmarks`, `metadata`, `lang`, `page_labels`, `warnings`, `tagged?`, `untagged_text` and
|
|
596
635
|
`structure` — a tagged PDF's structure tree as nested arrays, each element's text
|
|
597
636
|
read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
|
|
598
637
|
between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
|
|
@@ -661,10 +700,28 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
|
|
|
661
700
|
|
|
662
701
|
## Limitations
|
|
663
702
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
703
|
+
Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert to `.ttf` or
|
|
704
|
+
`.woff`); shaping stops at pair kerning and single or ligature substitutions (`liga` by default,
|
|
705
|
+
`smcp`, `onum`, `tnum`, `ss01`… on request), so contextual alternates (`calt`, `clig`, `frac`) do
|
|
706
|
+
nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph, and colour or emoji glyphs no font
|
|
707
|
+
in the chain has are drawn as `.notdef` and reported. Text runs left to right; hyphenation
|
|
708
|
+
patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
|
|
709
|
+
and justification only widens spaces.
|
|
710
|
+
|
|
711
|
+
SVG covers the shapes, gradients, text and stylesheets icon sets use, nothing else: `use`,
|
|
712
|
+
`image`, `clipPath`, `mask`, `pattern`, `filter` and `textPath` are skipped and reported.
|
|
713
|
+
Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
|
|
714
|
+
source resolution (only PNGs can be downscaled), so an oversized one is reported, not resized. `html` and `markdown` render structure and inline marks, not CSS: only `text-align`
|
|
715
|
+
and inline `font-weight`/`font-style` are read, and raw HTML inside Markdown stays literal text.
|
|
716
|
+
|
|
717
|
+
Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
|
|
718
|
+
splits only when every column can; a rotated box and a `stack` move to the next page whole. Text
|
|
719
|
+
does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
|
|
720
|
+
and `transform`, and `shadow:` is stacked rectangles, not a blur.
|
|
721
|
+
|
|
722
|
+
PDF: tagged output is not labelled PDF/UA (no XMP), and there is no PDF/A or PDF/X, no digital
|
|
723
|
+
signing (`signature_field` is an empty field), no embedded files or JavaScript.
|
|
724
|
+
Form-field appearances use Helvetica (Windows-1252), not the document's fonts.
|
|
668
725
|
|
|
669
726
|
## License
|
|
670
727
|
|
data/lib/stationery/builder.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Stationery
|
|
|
5
5
|
# (where the next node goes) and the text defaults in effect.
|
|
6
6
|
class Builder
|
|
7
7
|
STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning
|
|
8
|
-
ligatures].freeze
|
|
8
|
+
ligatures features hyphenate].freeze
|
|
9
9
|
|
|
10
10
|
# Collects a row's columns; anything that is not already a column box is
|
|
11
11
|
# wrapped in one.
|
|
@@ -32,10 +32,12 @@ module Stationery
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# `images` are the document's bitmap defaults (max_ppi:, downscale:).
|
|
36
|
+
attr_reader :root, :book, :list_depth, :images
|
|
36
37
|
|
|
37
|
-
def initialize(book:, text: {})
|
|
38
|
+
def initialize(book:, text: {}, images: {})
|
|
38
39
|
@book = book
|
|
40
|
+
@images = images
|
|
39
41
|
@root = Layout::Flow.new
|
|
40
42
|
@containers = [@root]
|
|
41
43
|
@text = [text]
|
|
@@ -8,14 +8,15 @@ module Stationery
|
|
|
8
8
|
|
|
9
9
|
# Draws `string` with its baseline at (x, y) in top-left coordinates and
|
|
10
10
|
# returns its advance width. Standard ligatures form unless `ligatures:`
|
|
11
|
-
# is false or letter spacing is set
|
|
11
|
+
# is false or letter spacing is set; `features:` are further OpenType
|
|
12
|
+
# feature tags (Strings) to apply.
|
|
12
13
|
def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
|
|
13
14
|
synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
|
|
14
|
-
kerning: false, ligatures: true, word_spacing: 0)
|
|
15
|
+
kerning: false, ligatures: true, features: Fonts::Font::NO_FEATURES, word_spacing: 0)
|
|
15
16
|
return 0 if string.empty?
|
|
16
17
|
|
|
17
18
|
color = Color.parse(color)
|
|
18
|
-
run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero
|
|
19
|
+
run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?, features:)
|
|
19
20
|
run = run.with_word_spacing(word_spacing, size) unless word_spacing.zero?
|
|
20
21
|
width = run.width(size, letter_spacing:)
|
|
21
22
|
graphics(opacity:) do |ops|
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -12,16 +12,19 @@ module Stationery
|
|
|
12
12
|
CAPS = { butt: 0, round: 1, square: 2 }.freeze
|
|
13
13
|
JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# `warnings` is the render's collector (nil on a bare canvas), for what a
|
|
16
|
+
# node notices only while painting, such as an oversized image.
|
|
17
|
+
attr_reader :page, :warnings
|
|
16
18
|
|
|
17
19
|
# `template: true` records anchors apart, for canvases page templates draw on.
|
|
18
20
|
# `tagging:` (a Tagging::Tree) marks content for a tagged PDF.
|
|
19
|
-
def initialize(page, resources, template: false, debug: false, tagging: nil)
|
|
21
|
+
def initialize(page, resources, template: false, debug: false, tagging: nil, warnings: nil)
|
|
20
22
|
@page = page
|
|
21
23
|
@resources = resources
|
|
22
24
|
@template = template
|
|
23
25
|
@debug = debug
|
|
24
26
|
@tagging = tagging
|
|
27
|
+
@warnings = warnings
|
|
25
28
|
@marked = 0
|
|
26
29
|
end
|
|
27
30
|
|
data/lib/stationery/document.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Stationery
|
|
|
24
24
|
superclass.config.transform_values(&:dup)
|
|
25
25
|
else
|
|
26
26
|
{ page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
|
|
27
|
-
templates: [], regions: [], strict: false, tagged: false }
|
|
27
|
+
templates: [], regions: [], strict: false, tagged: false, images: {} }
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -50,6 +50,21 @@ module Stationery
|
|
|
50
50
|
config[:metadata] = config[:metadata].merge(info)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# How viewers name the pages: a 1-based first page mapped to the range
|
|
54
|
+
# starting there, `page_labels 1 => { style: :roman_lower }, 3 =>
|
|
55
|
+
# { style: :decimal, start: 1, prefix: "A-" }`; see PDF::PageLabels.
|
|
56
|
+
def page_labels(spec)
|
|
57
|
+
PDF::PageLabels.entries(spec)
|
|
58
|
+
config[:page_labels] = spec
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Bitmap defaults: `max_ppi:` (300; nil disables) is the resolution
|
|
62
|
+
# above twice of which a drawn image is reported as oversized, and
|
|
63
|
+
# `downscale: true` resamples PNGs to it. See Layout::Image.
|
|
64
|
+
def images(**options)
|
|
65
|
+
config[:images] = config[:images].merge(options)
|
|
66
|
+
end
|
|
67
|
+
|
|
53
68
|
# Raise WarningsError instead of writing a PDF that produced warnings.
|
|
54
69
|
def strict(value = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
55
70
|
config[:strict] = value
|
|
@@ -94,9 +109,9 @@ module Stationery
|
|
|
94
109
|
def metadata = self.class.config[:metadata]
|
|
95
110
|
|
|
96
111
|
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
|
|
97
|
-
tagged: self.class.config[:tagged])
|
|
112
|
+
tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels])
|
|
98
113
|
Stationery.instrument("render.stationery", document: self.class.name) do |event|
|
|
99
|
-
write(render_pdf(event, strict:, debug:, encrypt:, tagged:), target)
|
|
114
|
+
write(render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:), target)
|
|
100
115
|
end
|
|
101
116
|
end
|
|
102
117
|
|
|
@@ -111,19 +126,21 @@ module Stationery
|
|
|
111
126
|
|
|
112
127
|
# Builds a page template or region block into a fresh root node.
|
|
113
128
|
def template_root(info, book:, &)
|
|
114
|
-
builder =
|
|
129
|
+
builder = builder_for(book)
|
|
115
130
|
build_with(builder) { instance_exec(info, &) }
|
|
116
131
|
builder.root
|
|
117
132
|
end
|
|
118
133
|
|
|
119
134
|
private
|
|
120
135
|
|
|
136
|
+
def builder_for(book) = Builder.new(book:, text: self.class.config[:text], images: self.class.config[:images])
|
|
137
|
+
|
|
121
138
|
# The PDF bytes; `event` is the render.stationery payload it fills in.
|
|
122
|
-
def render_pdf(event, strict:, debug:, encrypt:, tagged:)
|
|
139
|
+
def render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:)
|
|
123
140
|
tagging = Tagging::Tree.new if tagged
|
|
124
141
|
warnings = Warnings.new
|
|
125
142
|
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
|
|
126
|
-
builder =
|
|
143
|
+
builder = builder_for(book)
|
|
127
144
|
Stationery.instrument("build.stationery", document: self.class.name) { call(builder) }
|
|
128
145
|
resources = Resources.new
|
|
129
146
|
pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
|
|
@@ -135,13 +152,13 @@ module Stationery
|
|
|
135
152
|
event[:warnings] = warnings.size
|
|
136
153
|
raise WarningsError, warnings if strict && warnings.any?
|
|
137
154
|
|
|
138
|
-
assemble(pages, resources, outline, encrypt:, tagging:).tap { |pdf| event[:bytes] = pdf.bytesize }
|
|
155
|
+
assemble(pages, resources, outline, encrypt:, tagging:, page_labels:).tap { |pdf| event[:bytes] = pdf.bytesize }
|
|
139
156
|
end
|
|
140
157
|
|
|
141
|
-
def assemble(pages, resources, outline, encrypt:, tagging:)
|
|
158
|
+
def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:)
|
|
142
159
|
encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
|
|
143
160
|
assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:,
|
|
144
|
-
lang: metadata[:lang])
|
|
161
|
+
lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels))
|
|
145
162
|
Stationery.instrument("write.stationery", document: self.class.name) do |event|
|
|
146
163
|
assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
|
|
147
164
|
end
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -123,7 +123,7 @@ module Stationery
|
|
|
123
123
|
# `alt:` describes the image in a tagged PDF (false: decorative). Sizing,
|
|
124
124
|
# `fit: :cover`, `radius:` and `rotate:` as for Layout::Image.
|
|
125
125
|
def image(source, align: nil, **)
|
|
126
|
-
node = Layout::Image.new(source, **)
|
|
126
|
+
node = Layout::Image.new(source, **@_builder.images, **)
|
|
127
127
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
128
128
|
end
|
|
129
129
|
|
|
@@ -6,13 +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 (any Unicode White_Space), joiners, variation
|
|
10
|
-
# combining marks carry the family of the character before them (or
|
|
9
|
+
# Whitespace (any Unicode White_Space), soft hyphens, joiners, variation
|
|
10
|
+
# selectors and combining marks carry the family of the character before them (or
|
|
11
11
|
# after them at the start of a run) so words are not chopped and marks
|
|
12
12
|
# stay with their base; whitespace a font lacks draws as a blank of the
|
|
13
13
|
# right width (see Font::WHITESPACE).
|
|
14
14
|
class Fallback
|
|
15
|
-
CARRIED = /[\p{Space}
|
|
15
|
+
CARRIED = /[\p{Space}︀-️\p{M}]/
|
|
16
16
|
|
|
17
17
|
def self.carried?(char) = CARRIED.match?(char)
|
|
18
18
|
|
|
@@ -23,6 +23,7 @@ module Stationery
|
|
|
23
23
|
0x2005 => 0.25, 0x2006 => 1.0 / 6, 0x2009 => 0.2, 0x200A => 0.125, 0x205F => 4.0 / 18,
|
|
24
24
|
0x3000 => 1.0 }.freeze
|
|
25
25
|
SPACE_LIKE = { 0x2007 => "0", 0x2008 => "." }.freeze
|
|
26
|
+
NO_FEATURES = [].freeze
|
|
26
27
|
|
|
27
28
|
attr_reader :ttf
|
|
28
29
|
|
|
@@ -32,9 +33,9 @@ module Stationery
|
|
|
32
33
|
@pairs = {}
|
|
33
34
|
@glyphs = {}
|
|
34
35
|
@blanks = {}
|
|
35
|
-
@shapes = {
|
|
36
|
-
@advances = {
|
|
37
|
-
@kerns = {
|
|
36
|
+
@shapes = {}
|
|
37
|
+
@advances = {}
|
|
38
|
+
@kerns = {}
|
|
38
39
|
@cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
|
|
39
40
|
end
|
|
40
41
|
|
|
@@ -44,14 +45,19 @@ module Stationery
|
|
|
44
45
|
# word again (wrapping, then laying out the line) allocates nothing.
|
|
45
46
|
# Letter spacing is added per glyph, so a ligature counts once; any
|
|
46
47
|
# letter spacing turns ligatures off, as it does when drawing.
|
|
47
|
-
|
|
48
|
+
# `features:` are OpenType feature tags applied on top of `liga`.
|
|
49
|
+
def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true, features: NO_FEATURES)
|
|
48
50
|
ligatures &&= letter_spacing.zero?
|
|
49
|
-
|
|
51
|
+
key = shape_key(ligatures, features)
|
|
52
|
+
width = scale(advance_units(text, key), size) + (letter_spacing * shape(text, key).first.size)
|
|
50
53
|
return width unless kerning
|
|
51
54
|
|
|
52
|
-
width + (kerning_units(text,
|
|
55
|
+
width + (kerning_units(text, key) * size / 1000.0)
|
|
53
56
|
end
|
|
54
57
|
|
|
58
|
+
# The GSUB feature tags this font can apply.
|
|
59
|
+
def features = @ttf.ligatures.features
|
|
60
|
+
|
|
55
61
|
def ascender(size) = scale(@ttf.ascender, size)
|
|
56
62
|
def descender(size) = -scale(@ttf.descender, size)
|
|
57
63
|
def line_gap(size) = scale(@ttf.line_gap, size)
|
|
@@ -69,10 +75,11 @@ module Stationery
|
|
|
69
75
|
|
|
70
76
|
def encode(text) = glyph_run(text).gids.map { |gid| code(gid) }.pack("n*")
|
|
71
77
|
|
|
72
|
-
# `ligatures:` substitutes the font's standard ligatures
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
# `ligatures:` substitutes the font's standard ligatures and `features:`
|
|
79
|
+
# any further OpenType features; `kerning:` then fills the adjustments
|
|
80
|
+
# with pair kerning between the glyphs.
|
|
81
|
+
def glyph_run(text, kerning: false, ligatures: true, features: NO_FEATURES)
|
|
82
|
+
gids, chars, blanks = shape(text, shape_key(ligatures, features))
|
|
76
83
|
gids.each_with_index { |gid, i| @used[gid] ||= blanks[i] ? " " : chars[i] }
|
|
77
84
|
adjust = gids.each_with_index.map do |gid, i|
|
|
78
85
|
kern = kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0
|
|
@@ -118,19 +125,30 @@ module Stationery
|
|
|
118
125
|
|
|
119
126
|
private
|
|
120
127
|
|
|
128
|
+
# The feature tags to substitute with: `liga` when ligatures are on, plus
|
|
129
|
+
# the requested features. Frozen and shared, so it keys the memos cheaply.
|
|
130
|
+
def shape_key(ligatures, features)
|
|
131
|
+
features = Text::Style.features(features) unless features.frozen? && features.all?(String)
|
|
132
|
+
return features unless ligatures
|
|
133
|
+
return Gsub::LIGA if features.empty?
|
|
134
|
+
|
|
135
|
+
@keys ||= {}
|
|
136
|
+
@keys[features] ||= (features + Gsub::LIGA).sort.freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
121
139
|
# [gids, source text of each glyph, extra advance in font units after
|
|
122
|
-
# each blank or nil], remembered per string.
|
|
123
|
-
def shape(text,
|
|
124
|
-
@shapes[
|
|
140
|
+
# each blank or nil], remembered per string and feature tags.
|
|
141
|
+
def shape(text, tags)
|
|
142
|
+
(@shapes[tags] ||= {})[text] ||= begin
|
|
125
143
|
gids = text.each_char.map { |char| glyph_for(char) }
|
|
126
|
-
gids, chars =
|
|
144
|
+
gids, chars = tags.empty? ? [gids, text.chars] : substitute(gids, text, tags)
|
|
127
145
|
[gids, chars, chars.map { |char| blank_units(char) }].each(&:freeze).freeze
|
|
128
146
|
end
|
|
129
147
|
end
|
|
130
148
|
|
|
131
|
-
def
|
|
149
|
+
def substitute(gids, text, tags)
|
|
132
150
|
start = 0
|
|
133
|
-
glyphs = @ttf.ligatures.substitute(gids)
|
|
151
|
+
glyphs = @ttf.ligatures.substitute(gids, tags)
|
|
134
152
|
chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
|
|
135
153
|
[glyphs.map(&:first), chars]
|
|
136
154
|
end
|
|
@@ -152,15 +170,15 @@ module Stationery
|
|
|
152
170
|
width - space
|
|
153
171
|
end
|
|
154
172
|
|
|
155
|
-
def advance_units(text,
|
|
156
|
-
@advances[
|
|
157
|
-
gids, _, blanks = shape(text,
|
|
173
|
+
def advance_units(text, tags)
|
|
174
|
+
(@advances[tags] ||= {})[text] ||= begin
|
|
175
|
+
gids, _, blanks = shape(text, tags)
|
|
158
176
|
gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 }
|
|
159
177
|
end
|
|
160
178
|
end
|
|
161
179
|
|
|
162
|
-
def kerning_units(text,
|
|
163
|
-
@kerns[
|
|
180
|
+
def kerning_units(text, tags)
|
|
181
|
+
(@kerns[tags] ||= {})[text] ||= shape(text, tags).first.each_cons(2).sum { |left, right| pair(left, right) }
|
|
164
182
|
end
|
|
165
183
|
|
|
166
184
|
# Kerning between two glyphs in thousandths of an em.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gsub
|
|
6
|
+
# SingleSubst (GSUB lookup type 1): one glyph for another. Format 1
|
|
7
|
+
# adds a delta to every covered glyph, format 2 lists the substitute
|
|
8
|
+
# for each covered glyph in coverage order. Small caps, oldstyle and
|
|
9
|
+
# tabular figures, stylistic sets and slashed zero are all this.
|
|
10
|
+
class SingleSubst
|
|
11
|
+
def self.read(ttf, offset)
|
|
12
|
+
format = ttf.u16(offset)
|
|
13
|
+
coverage = Gpos::Coverage.read(ttf, offset + ttf.u16(offset + 2))
|
|
14
|
+
case format
|
|
15
|
+
when 1
|
|
16
|
+
delta = ttf.i16(offset + 4)
|
|
17
|
+
new(coverage.to_h { |gid| [gid, (gid + delta) & 0xFFFF] })
|
|
18
|
+
when 2
|
|
19
|
+
substitutes = ttf.data.byteslice(offset + 6, ttf.u16(offset + 4) * 2).unpack("n*")
|
|
20
|
+
new(coverage.zip(substitutes).to_h.compact)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(map)
|
|
25
|
+
@map = map.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# [substitute gid, 1] for a covered glyph at `index`, or nil.
|
|
30
|
+
def match(gids, index)
|
|
31
|
+
gid = @map[gids[index]]
|
|
32
|
+
[gid, 1] if gid
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|