stationery 0.10.1 → 0.11.1
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 +29 -0
- data/README.md +298 -37
- data/lib/stationery/builder.rb +9 -0
- data/lib/stationery/canvas/text.rb +8 -2
- data/lib/stationery/css/stylesheet.rb +9 -1
- data/lib/stationery/document.rb +56 -14
- data/lib/stationery/elements.rb +31 -3
- data/lib/stationery/errors.rb +5 -1
- data/lib/stationery/fonts/font.rb +49 -2
- data/lib/stationery/fonts/font_book.rb +14 -5
- data/lib/stationery/fonts/shaped_run.rb +160 -0
- data/lib/stationery/fonts/shaping.rb +63 -0
- data/lib/stationery/forms/typeface.rb +10 -3
- data/lib/stationery/html/css.rb +68 -8
- data/lib/stationery/html/tree_builder/blocks.rb +14 -4
- data/lib/stationery/images/images.rb +6 -6
- data/lib/stationery/images/resampled.rb +3 -2
- data/lib/stationery/images/webp/bit_reader.rb +78 -0
- data/lib/stationery/images/webp/container.rb +62 -0
- data/lib/stationery/images/webp/image_stream.rb +168 -0
- data/lib/stationery/images/webp/lossless.rb +101 -0
- data/lib/stationery/images/webp/predictor.rb +118 -0
- data/lib/stationery/images/webp/prefix_code.rb +168 -0
- data/lib/stationery/images/webp/transforms.rb +93 -0
- data/lib/stationery/images/webp.rb +71 -0
- data/lib/stationery/layout/box/wrapping.rb +52 -0
- data/lib/stationery/layout/box.rb +38 -12
- data/lib/stationery/layout/columns/balancer.rb +66 -0
- data/lib/stationery/layout/columns/leveller.rb +65 -0
- data/lib/stationery/layout/columns/pour.rb +54 -0
- data/lib/stationery/layout/columns.rb +208 -0
- data/lib/stationery/layout/floated.rb +50 -0
- data/lib/stationery/layout/flow/floating.rb +51 -0
- data/lib/stationery/layout/flow/placement.rb +148 -0
- data/lib/stationery/layout/flow/splitter.rb +194 -0
- data/lib/stationery/layout/flow.rb +20 -117
- data/lib/stationery/layout/image.rb +1 -1
- data/lib/stationery/layout/list_item.rb +30 -9
- data/lib/stationery/layout/mark.rb +3 -1
- data/lib/stationery/layout/node.rb +14 -0
- data/lib/stationery/layout/paginator.rb +7 -2
- data/lib/stationery/layout/table.rb +52 -6
- data/lib/stationery/layout/text.rb +31 -12
- data/lib/stationery/minitest.rb +6 -3
- data/lib/stationery/page.rb +26 -1
- data/lib/stationery/page_templates.rb +1 -1
- data/lib/stationery/pdf/assembler.rb +20 -4
- data/lib/stationery/pdf/conformance.rb +16 -3
- data/lib/stationery/pdf/page_sealer.rb +35 -0
- data/lib/stationery/rich/nodes.rb +4 -3
- data/lib/stationery/rich/renderer/boxes.rb +55 -12
- data/lib/stationery/rich/renderer.rb +8 -5
- data/lib/stationery/rich/styles.rb +1 -1
- data/lib/stationery/shaper.rb +109 -0
- data/lib/stationery/tagging/element.rb +6 -0
- data/lib/stationery/tagging/tree.rb +53 -2
- data/lib/stationery/testing/field_page.rb +89 -0
- data/lib/stationery/testing/inspector.rb +12 -2
- data/lib/stationery/testing/marked_text.rb +124 -18
- data/lib/stationery/testing/matchers.rb +26 -14
- data/lib/stationery/text/exclusions.rb +43 -0
- data/lib/stationery/text/line.rb +7 -2
- data/lib/stationery/text/paragraph.rb +69 -10
- data/lib/stationery/text/wrapper/remainder.rb +59 -0
- data/lib/stationery/text/wrapper.rb +54 -10
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +10 -1
- data/lib/stationery.rb +11 -0
- metadata +25 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b208319414f7dea1a6af8a89b47aec788e1135ace442031a1d05623240483d7c
|
|
4
|
+
data.tar.gz: f9d15f729afcfd2fa6861b01b365462b4a5d413a4ce9b6fb101c2b7c3b9f68db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fca53eecf78b67b65c7bb9b9582d625f6e693ba9ff29264423f9c1028d3ea8b2477d69af7858edde8169b91b592d43a364d4874703cf719173158c09f187f4b1
|
|
7
|
+
data.tar.gz: 9bd72b967a7e1cd32acf650e5662d009cf792e23f1fef9cec3df579b74fad916f2ed4a0d6d96bdd0b0bbefcecc85f7d277510ff69b486c742d0d00ae169ac8da
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.1 (2026-09-28)
|
|
4
|
+
|
|
5
|
+
What putting 0.11.0 to work found: PDF/UA claims that hold, boxes that stay on their page, columns that fill evenly, and lists that widen below a float.
|
|
6
|
+
|
|
7
|
+
- PDF/UA-1 refuses heading levels that are skipped (ISO 14289-1, 7.4.2; veraPDF rule 7.4.2-1): the first heading of a document is `heading: 1` and a heading is at most one level below the heading before it, so `heading: 1` followed by `heading: 3`, or a document that starts at `heading: 2`, raises `Stationery::ConformanceError` with an issue per heading (`heading 3 on page 2 skips a level: heading 2 is the deepest that may follow heading 1`) instead of writing a file that claims a level it does not keep. Going back up is free (`3`, then `1`), and a heading that skipped is what the next one is measured against. Headings are read in the order of the structure tree, which is what a validator follows, inside sections, lists, table cells, columns and floats, from `text(heading:)`, `html` and `markdown` alike; the headings of headers, footers and page templates are artifacts and a heading without text is not written, so neither counts. In a tagged render without the claim it is a `Warnings::SkippedHeading` (`level`, `allowed`, `page`), so `strict` catches it.
|
|
8
|
+
- A blank `alt:` is a missing one: `image(source, alt: "")`, `svg(source, alt: " ")` and a `markdown` image without a description (``) are reported as `Warnings::MissingAlt` in a tagged render and raise `ConformanceError` under PDF/UA-1 (ISO 14289-1, 7.3; veraPDF rule 7.3-1 fails an empty `/Alt`), where only a missing `alt:` was caught. An `alt:` of whitespace alone is refused as well, although veraPDF accepts it: it describes as little as the empty one. `alt: false` still marks decoration, which is an artifact and needs no description, and the message of `MissingAlt` now names it: `image on page 1 has no alt: text (alt: false marks decoration)`.
|
|
9
|
+
- In `html`, `<img alt="">` is decoration, as HTML means it: an `alt` attribute that is there and empty (or whitespace alone) draws the image as `alt: false` does, an artifact outside the structure tree, floated or not, so it neither warns nor raises. An `<img>` without an `alt` attribute is still a missing description. Markdown has no way to say that an image is decorative, so `` counts as a description forgotten: use `html` or the `image` element for decoration.
|
|
10
|
+
- A `box` that splits across pages no longer runs over the page by the padding and border that close it. A head is open at the bottom, so its content was cut at the height less the top of the box alone: content that ended on the page by that measure was placed with the bottom padding under it, up to that much over the edge, and reported as an `Overflow`. The last of the content now goes to the next page with what closes the box; content that cannot be cut there moves whole, and first on a fresh page it is kept and reported as before. Boxes inside boxes added their paddings up: 29 of 200 random flows of nested padded boxes overflowed, none do. A box that did not overflow is laid out as it was.
|
|
11
|
+
- Balanced `columns` fill evenly: ten lines in three columns are 4, 3 and 3 (they were 4, 4 and 2, the last column taking what was left over), thirteen in four are 4, 3, 3 and 3. At the balanced height the first column takes what fits, and what it leaves is balanced through the columns after it in the same way, down to the last two, so what cannot be shared goes to the earlier columns. Every column is still cut where a page would cut it, so `orphans:`/`widows:`, `keep_with_next`, `break_inside: :avoid` and blocks that cannot split hold as they did; the columns filled in order are kept where the levelled ones would not place everything, would change the height of the block, or would have a later column taller than an earlier one where there was none. The block is exactly as tall as it was, so what follows it stays where it is, and a block of two columns, `balance: false`, the full pages of a block that continues and documents without `columns` are byte for byte what they were. A block is levelled once, when it is placed, with at most `(count - 2) * 41` pours more than its balancing: 700 paragraphs in two and three columns render in the time they did. `Layout::Columns::Leveller`, `Layout::Columns::Pour#first`.
|
|
12
|
+
- Beside a float, a list item and a `box` that paints nothing of its own wrap what they hold: they keep the full width of their flow, and their lines are narrow beside the float and take the full width below it, in the middle of a paragraph if need be. They were blocks of the width the float left, all the way down, so a long item or box beside a short image stayed narrow below it. That is a box without `background:`, `border:`, `shadow:` or `link:` and without `width:`, `height:`, `rotate:`, `overflow:` or `valign:`; its padding lies under the float, as a block's does in CSS. A list item keeps its indent from the float, with the marker beside its first line. They split across pages as they did (`break_inside:`, `keep_with_next:`, `orphans:` and `widows:` inside them), the lines carried over are wrapped again at the full width, and a tagged PDF reads them in the order they were written. In `html`, the items of a list beside a floated `img` wrap, and so does a block indented by `margin-left` or `margin-right`, whose margin lies under the float. A box with a background, a border, a shadow or a link stays a block of the width that is left: it is painted after the float written before it, and at the full width it would paint over the float. So do a `table`, a `row` and `columns`, whose columns depend on their width. Documents without floats are byte for byte what they were, and allocate what they did. `Layout::Box::Wrapping`, `Text::Exclusions#inset`.
|
|
13
|
+
- `html` reads `margin-left` and `margin-right`, which were reported as `Warnings::UnsupportedCss`: lengths in `px` or `pt`, as `margin-top` takes them. On a floated `img` they are that side of the space kept around it, so `style="float: right; margin-left: 12pt"` keeps 12 pt between the image and the text. On a block (`p`, `div`-like containers, headings, lists, tables, `blockquote`, `pre`) they indent it from that side, its background and padding inside the margin; the block breaks across pages where it did, and a heading or a paragraph keeps its `keep_with_next`. `margin` and its sides are read in the order of the cascade, specificity first and position then: a side wins over a `margin` declared before it, and a `margin` takes back the sides declared before it. `auto`, `em` and `%` are reported as they were, and so is a negative `margin-left` or `margin-right`. HTML without margins renders byte for byte as before. `CSS::Stylesheet#matching`.
|
|
14
|
+
- Testing: `Inspector#text` and `#page_texts`, and so `have_pdf_text`, `have_pdf_text_on_page` and `assert_pdf_text`, read a sequence with `/ActualText` as that text, once, whatever the glyphs inside it advance. A shaped stretch whose first glyph has no advance (a mark drawn before its base, as HarfBuzz answers for Arabic) was missing from the text: pdf-reader gives the `ActualText` to the first glyph shown and drops a glyph without a width. The helpers now read a page through the receiver `Inspector#structure` already read it with: glyphs outside such a sequence are laid out by pdf-reader as before, and the sequence is one run from where its first glyph is shown to where it ends. The text of a page without `ActualText` is what it was. `ActualText` and the structure tree are read with pdf-reader 2.12 and later, where they needed 2.16. For a PDF Stationery did not write, `untagged_text` and `structure` follow a form XObject the page draws, as `text` does. The PDFs written are unchanged.
|
|
15
|
+
- Testing: `fields: true` reads what form fields show. A field's value is drawn by its widget's appearance, a form XObject of the annotation and no part of the page content, so `have_pdf_text("Astrid")` does not find the value of `text_field "name", value: "Astrid"`. `have_pdf_text("Astrid", fields: true)`, `have_pdf_text_on_page(1, "Astrid", fields: true)`, `assert_pdf_text pdf, "Astrid", fields: true`, `refute_pdf_text`, `Inspector#text(fields: true)` and `#page_texts(fields: true)` read the normal appearance of every widget with the page content, each where the widget is on its page: text fields (every line of `multiline:`, every cell of `comb:`), a select's value, a signature field's label, characters no font has from their `ActualText`, a field set in the standard Helvetica, in an encrypted document as in a plain one. A widget that is hidden (`Hidden`, `NoView`) is not read, and of a button's appearances the one its state names, never `Off`. It is an option and not the default, so no text a spec reads today changes: the text of a form with its fields is other text (more words, and other blank lines between those of the page content), which `not_to have_pdf_text` and a comparison of `Inspector#text` would notice. Without the option, the failure of `have_pdf_text` over a text that a field shows says so.
|
|
16
|
+
- **Behaviour changes:** in `html`, the left and right of the `margin` shorthand indent a block, as CSS means them: `margin: 0 20px` drew no indent. Top and bottom draw what they drew. A `margin` declared after `margin-top` or `margin-bottom`, or by a more specific rule, takes them back, where the side always won. A negative margin on a floated `img` is drawn as none: it moved the image out of the flow, off the page when it was large, which nothing in a style may do. Beside a float, a list or a box without a background or a border is laid out at the full width with its lines around the float, where it was laid out at the width left beside it; a document with floats and such content may break its pages elsewhere. A render with `conformance :pdf_ua1` whose heading levels skip, or with a figure whose `alt:` is blank, now raises `ConformanceError` (it wrote a file veraPDF rejected); a tagged render reports both as warnings, so with `strict` it raises `WarningsError`. PDF/A alone asks for neither (level B does not ask for structure) and raises for neither. In a tagged render an `html` image with `alt=""` is an artifact, where it was a `Figure` with an empty `/Alt`. The message of `MissingAlt`, and with it the issue of the `ConformanceError`, ends in `(alt: false marks decoration)`. A `box` that ran over the page by the padding or border that closes it breaks a line or more earlier, and no longer reports an `Overflow`. Documents that are not tagged, and tagged documents that keep both rules and have no `<img alt="">`, are byte for byte what they were.
|
|
17
|
+
|
|
18
|
+
## 0.11.0 (2026-09-28)
|
|
19
|
+
|
|
20
|
+
Text wrap around images, balanced columns, lossless WebP, a shaper hook for complex scripts, and far less memory for long documents.
|
|
21
|
+
|
|
22
|
+
- Floats: `image(source, float: :left | :right, margin:)` and `box(float:, margin:, width:) { … }` take an image or a box to the edge of the flow it is written in (page body, box, column, cell), and what follows wraps beside it: every line of a paragraph takes the width left at its own top, aligned and justified in it, and the full width again below the float, mid-paragraph. `margin:` is a number (kept on the sides that face the text) or names the sides as `padding:` does; a floated box needs a `width:` (points, a fraction or `:auto`). Tables, boxes, rows, list items and other blocks go beside the float in the width that is left when they fit, else below it. A second float goes beside the first when it fits, else below; left and right floats coexist; a paragraph whose widest word does not fit starts below them. The flow is as tall as its floats. A float never splits: it moves to the next page with the content that wraps around it, and the lines a paragraph carries over a page break are wrapped again at the full width. In a tagged PDF the float stays where it was written, a `Figure` or the box's `role:`. `html` reads `float: left|right` on `img` and `<img align="left|right">`, with the image's CSS `margin` or `styles: { img: { float_margin: 8 } }`; `float` on any other element is still reported as `Warnings::UnsupportedCss`. `examples/article.rb` shows a floated photo and a pull quote. Documents without floats are byte for byte what they were, and allocate what they did.
|
|
23
|
+
- `columns(count: 2, gap: 12, balance: true, rule: nil) { … }`: one flow poured through columns, newspaper style (a `row` is columns side by side with content of their own). The content is laid out at the column width and cut where a page break would cut it: between children, between the lines of a paragraph (`orphans:`/`widows:`), inside boxes and tables by their own rules, never inside `break_inside: :avoid`, and `keep_with_next` keeps a heading in the column of what follows it. `balance: true` ends the columns at nearly the same height where the content ends (the last page of the block, and before a `page_break` inside it) by searching for the shortest column height that holds everything; `balance: false` fills each column before the next. A block that does not fit fills the height left on the page and continues on the next; below other content it starts only when every column takes something, and otherwise moves to the next page. What follows the block starts below its tallest column. `rule: true | { color:, width: }` draws a line between the columns (an artifact in a tagged PDF). Works inside boxes and inside another `columns`; a tagged PDF reads column 1, then column 2, with a paragraph split across columns as one `P`. `html` maps `column-count`, `columns: <n>` and `column-gap` on `div`-like containers, `p`, headings, lists, tables, block quotes and `pre` to it. `examples/newsletter.rb`. `Layout::Columns`, `Layout::Columns::Pour`, `Layout::Columns::Balancer`.
|
|
24
|
+
- Lossless WebP: `image "photo.webp"` embeds a lossless WebP (VP8L, in a plain or an extended `VP8X` container, whose colour profile and metadata chunks are skipped), decoded in Ruby with no dependency: prefix codes, backward references, the colour cache and the predictor, cross-colour, subtract-green and colour-indexing transforms. It is embedded like a PNG, RGB as a Flate stream with the alpha channel as a soft mask when a pixel is not opaque, and takes everything `image` offers, `downscale: true` included. A 1000 × 1000 px image decodes in 0.2 to 0.4 s, once per process. Lossy and animated WebP still raise `UnsupportedImage`, now naming which it is, as do truncated and damaged files and images of more than 33 megapixels (2^25 pixels). `image.stationery` reports `format: "WebP"`; the message for an unknown format reads "only JPEG, PNG and lossless WebP images are supported".
|
|
25
|
+
- Shaper hook: `shaper MyShaper` at class level (inherited; `shaper nil` takes it away) or `to_pdf(shaper:)` hands every text to an object answering `call(text, font, size:, features:, language:)`, so an application with HarfBuzz can set Arabic, Hebrew, Indic or Thai text. It answers `Stationery::Shaper::Glyph`s (`gid`, `advance`, `x_offset`, `y_offset` in font units, `cluster`) in visual order, or `nil` to decline a text; `font` is a `Stationery::Shaper::Face` (`path`, `index`, `data`, `units_per_em`, `postscript_name`, `glyph_count`). Line breaking measures words through the shaper, then every stretch of a line in one font and style is shaped whole, measured and drawn from that one answer: advances as `TJ` adjustments against the font's own widths, offsets as pen movements and text rise. Shaped glyphs are embedded whatever they map to, and glyphs the ToUnicode map cannot speak for (out of logical order, several for one cluster, standing for another text already) are shown in a `Span` with the characters as `ActualText`. Glyph 0 is a `Warnings::MissingGlyph`; an answer that cannot be drawn raises `Stationery::ShaperError`. The gem shapes nothing itself and has no new dependency: there is no reordering across stretches or lines, justification widens spaces only, form fields are not shaped, and poppler and MuPDF return a right-to-left `ActualText` reversed. `examples/shaping/harfbuzz_shaper.rb` is an adapter for the `harfbuzz-ruby` gem. The memo of shaped runs starts over after 64 KB of text, like the font's own. Without a shaper every render is byte for byte what it was.
|
|
26
|
+
- Long documents hold far less while they render, and every form of `to_pdf` writes the bytes it wrote. A page's nodes are let go once it is painted (the root flow, the builder and `FontBook`'s record of split runs no longer keep them), the fonts' shape and metrics memos start over after 64 KB of text instead of keeping every line drawn, a table without rowspans measures a row when a page reaches it (`Node#height_within`), a flow copies the children still to come once a page instead of once a child, and a page nothing paints on again keeps its deflated stream instead of its operators. Peak resident memory of 1,000 pages of text 391 → 111 MB and of 5,001 pages 1,556 → 350 MB; a table of 33,000 rows 901 → 653 MB and one of 165,000 rows 3,792 → 2,952 MB (what is alive when pagination ends: 216 → 17, 1,062 → 41, 494 → 28 and 2,437 → 65 MB). What is left is the document as it was built, which for a table is a node for every cell. `bundle exec rake memory` measures it.
|
|
27
|
+
- `to_pdf(incremental: true)`, or `incremental` at class level, writes each page's content as soon as the page is painted, to a block before the next page is painted. For documents with headers, footers, page templates or a table of contents, which otherwise hold every page's operators until the page count is known: 5,189 pages with a footer peak at 446 MB instead of 614 MB (1,643 MB before), with 20 MB alive at the end of pagination instead of 180 MB. The file has its page contents first and fonts, page tree and catalog last, and what is painted once every page is known is a content stream of its own under or over the body, so it is a little larger (1.5 → 1.7 MB for 1,039 pages). A render with `conformance:` or `sign:`, and a `strict` one that goes to a block, take the usual path, since they must be checked before a byte is written; to a block an error on a late page leaves the start of a file. A document without headers, footers, templates or contents gains nothing from it.
|
|
28
|
+
- A character no font has raises under a conformance level: text may not reference `.notdef` under PDF/UA-1 (ISO 14289-1, 7.21.8) nor under PDF/A-2b and PDF/A-3b (ISO 19005-2/3, 6.2.11.8; veraPDF rules 7.21.8-1 and 6.2.11.8-1), so `conformance` now raises `Stationery::ConformanceError` with an issue per character and family (`"☃" (U+2603) is in no font of Brand: add a font or font_fallbacks that covers it`) instead of writing a file that claims a level it does not keep. Body text, page templates and form field values are all covered. Whitespace a font lacks draws as a blank and is accepted, and without `conformance` a missing glyph stays a `MissingGlyph` warning.
|
|
29
|
+
- Form field appearances keep characters no font has, as body text does since 0.10.1: a text field's value (single line, `multiline:` and `comb:` cells), a select's value and a signature field's label wrap each run of `.notdef` glyphs in a `/Span` with `/ActualText`, inside the text object and inside `/Tx BMC … EMC`, so `text_field "name", value: "日本語"` extracts as `日本語`. The appearances were already written this way; it is now specified and documented. The `MissingGlyph` warning and the bytes of values the fonts cover are unchanged. A field made without a font book (`Forms::Field.new` on a bare canvas) still writes a character outside Windows-1252 as `?` without `ActualText`: that is a real glyph, and `/V` holds the value.
|
|
30
|
+
- **Behaviour changes:** a render with `conformance` that draws a character no font has now raises `ConformanceError` (it wrote a file veraPDF rejected), under PDF/A-2b and PDF/A-3b as well as PDF/UA-1. In `html`, `<img align="left|right">` floats the image, where it drew a block of its own. The lines a paragraph carries over a page break are wrapped again when the width they continue at is another. `image` and `box` raise `ArgumentError` for `margin:` without `float:` and for `float:` with `at:`. The message of `UnsupportedImage` for an unknown format names lossless WebP, and a lossy or animated WebP is named as such. `rake verify:conformance` also validates `examples/article.rb` and `examples/newsletter.rb`.
|
|
31
|
+
|
|
3
32
|
## 0.10.1 (2026-09-28)
|
|
4
33
|
|
|
5
34
|
- Extracted text keeps characters no font has: each run of `.notdef` glyphs is wrapped in a `/Span` with `/ActualText` holding the written characters, and ToUnicode maps glyph 0 to U+FFFD instead of the first missing character drawn, so `日本語` set in a font without those glyphs copies out as `日本語`, not `日日日`. The `MissingGlyph` warning and the bytes of text the fonts cover are unchanged.
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Pure-Ruby PDF documents built from Phlex-style components. Describe the page
|
|
4
4
|
with rows, columns, boxes, tables, text and images; a box-layout engine
|
|
5
5
|
measures, places and paginates them; a small PDF writer embeds subsetted
|
|
6
|
-
TrueType and OpenType fonts, JPEG
|
|
6
|
+
TrueType and OpenType fonts, JPEG, PNG and lossless WebP images and SVG drawings.
|
|
7
7
|
|
|
8
8
|
**Documentation: [stationery.zoolutions.llc](https://stationery.zoolutions.llc)**
|
|
9
9
|
|
|
@@ -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, fillable form, postcard collage, event flyer and Factur-X e-invoice
|
|
73
|
+
`examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage, magazine article, event flyer, two-column newsletter and Factur-X e-invoice
|
|
74
74
|
([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
|
|
75
75
|
renders them all, or render one with `stationery render examples/report.rb`.
|
|
76
76
|
|
|
@@ -81,10 +81,11 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
81
81
|
| `text(string, **style)` | A paragraph. Plain strings are literal. |
|
|
82
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. |
|
|
83
83
|
| `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
|
|
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
|
+
| `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, float:, margin:) { }` | 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. `float: :left` or `:right` with a `width:` takes it to that side, `margin:` away from the text that wraps beside it: see [Floats](#floats). |
|
|
85
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. |
|
|
86
|
+
| `columns(count:, gap:, balance:, rule:) { }` | One flow poured through `count` columns, newspaper style: column 1 top to bottom, then column 2. Breaks where a page would (between lines with `orphans:`/`widows:`, never inside `break_inside: :avoid`, `keep_with_next` honoured). `balance: true` (default) ends the columns at nearly the same height where the content ends and fills them evenly, what cannot be shared going to the earlier columns (ten lines in three columns are 4, 3 and 3); `false` fills each before the next. Continues across pages; `page_break` inside ends the page; `rule: true \| { color:, width: }` draws a line between columns. |
|
|
86
87
|
| `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. |
|
|
87
|
-
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:)` | JPEG or
|
|
88
|
+
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:, float:, margin:)` | JPEG, PNG or lossless WebP, 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 or WebP to that resolution instead. |
|
|
88
89
|
| `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:` (or the `color` an element sets). 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); `use`, `symbol` sprites and nested `svg` viewports (`viewBox`, `preserveAspectRatio`); `clipPath` (both `clipPathUnits`). |
|
|
89
90
|
| `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
|
|
90
91
|
| `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. |
|
|
@@ -112,6 +113,55 @@ Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:i
|
|
|
112
113
|
ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
|
|
113
114
|
Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
|
|
114
115
|
|
|
116
|
+
### Floats
|
|
117
|
+
|
|
118
|
+
`float: :left` or `:right` on an `image` or a `box` takes it to that edge of the flow it is written
|
|
119
|
+
in (the page body, a box, a column, a table cell); what follows it in that flow starts at its top
|
|
120
|
+
and wraps beside it, back to the full width below it, in the middle of a paragraph if need be.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
image "bay.jpg", float: :left, width: 0.4, margin: 12, alt: "The bay at dawn"
|
|
124
|
+
text story, align: :justify # beside the photo, then below it
|
|
125
|
+
|
|
126
|
+
box(float: :right, width: 170, margin: { left: 16, bottom: 6 }, padding: 10, role: :blockquote) do
|
|
127
|
+
text "The view is everywhere.", size: 13, style: :italic
|
|
128
|
+
end
|
|
129
|
+
text more # around the pull quote
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- `margin:` is the space between the float and the text: a number is kept on the sides that face
|
|
133
|
+
the text (the inner side and the bottom); a Hash (`{ left: 16, bottom: 6 }`, `x:`, `y:`) or an
|
|
134
|
+
Array names the sides as `padding:` does. A floated box needs a `width:`: points, a fraction of
|
|
135
|
+
the flow's width or `:auto`. Anything but `:left` and `:right` raises `ArgumentError`.
|
|
136
|
+
- Text (paragraphs, headings, the text inside a `group` or an `html` block) wraps: every line takes
|
|
137
|
+
the width left at its own top and is aligned and justified in it. A paragraph whose widest word
|
|
138
|
+
does not fit beside the floats starts below them.
|
|
139
|
+
- A list item and a `box` that paints nothing of its own wrap what they hold: they keep the full
|
|
140
|
+
width, and their lines are narrow beside the float and wide below it. That is a box without
|
|
141
|
+
`background:`, `border:`, `shadow:` or `link:` and without `width:`, `height:`, `rotate:`,
|
|
142
|
+
`overflow:` or `valign:`. Its padding lies under the float, as a block's does in CSS, so beside
|
|
143
|
+
the float its lines are the float's `margin:` away from it. A list item keeps its indent from the
|
|
144
|
+
float, with the marker beside its first line.
|
|
145
|
+
- Anything else (a `box` with a background, a border or a size of its own, a `row`, `columns`, `table`,
|
|
146
|
+
`rule`, `image`, `svg`, form field) is a block:
|
|
147
|
+
it goes beside the float in the width that is left, and keeps that width all the way down, when
|
|
148
|
+
its own width (or the least its content takes) fits there; else it starts below the float. A
|
|
149
|
+
`spacer` takes its height beside the float; a `page_break` ends the page and the float with it.
|
|
150
|
+
- Several floats: the next one goes beside those already there when it fits, else below them, and
|
|
151
|
+
never above one written before it. Left and right floats share a line with the text between them.
|
|
152
|
+
- The flow that holds a float is at least as tall as the float, so what follows a box, a column or a
|
|
153
|
+
cell starts below the floats inside it.
|
|
154
|
+
- Across pages a float never splits. When it does not fit what is left of the page, or the content
|
|
155
|
+
after it could not start beside it there, it moves to the next page with that content. A
|
|
156
|
+
paragraph beside a float splits between lines as always (`orphans:`, `widows:`); the lines carried
|
|
157
|
+
over are wrapped again at the full width, because the float stayed behind. So are those of a list
|
|
158
|
+
item and of a box.
|
|
159
|
+
- In a tagged PDF the float is where it was written: an image is a `Figure`, a box has its `role:`.
|
|
160
|
+
|
|
161
|
+
A line beside a float is taken to be as tall as a line of the paragraph's own style when its width
|
|
162
|
+
is looked up, so one much taller word may reach a little past a float's bottom edge before the text
|
|
163
|
+
widens.
|
|
164
|
+
|
|
115
165
|
### HTML and Markdown
|
|
116
166
|
|
|
117
167
|
`html` and `markdown` render user content with the elements above; their parsers load on first use.
|
|
@@ -133,7 +183,7 @@ end
|
|
|
133
183
|
monospace family for inline and block code, otherwise the text font is used), `pre` and
|
|
134
184
|
`blockquote` (box options), `hr` (rule options), `table` (`cell:` options, `header:` text style),
|
|
135
185
|
`ul` and `ol` (list options: `gap:`, `indent:`, `marker_gap:`, `marker_color:`, plus `style:` for
|
|
136
|
-
`ul` and `format:`/`suffix:` for `ol`), `li` (text style) and `img` (`max_width:`).
|
|
186
|
+
`ul` and `format:`/`suffix:` for `ol`), `li` (text style) and `img` (`max_width:`, and `float_margin:` for a floated image without a CSS margin).
|
|
137
187
|
- Links are written only for `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`); anything
|
|
138
188
|
else (`javascript:`, `data:`, a relative path) keeps its text without a link and is reported as a
|
|
139
189
|
`DroppedLink` warning. `links: %w[http https]` changes the list, `links: :all` keeps every href
|
|
@@ -170,14 +220,19 @@ ignored and reported. At-rules are skipped, except that rules inside `@media pri
|
|
|
170
220
|
| `text-align` | `left`, `center`, `right`, `justify` | paragraphs, headings, cells, images; inherited from a container |
|
|
171
221
|
| `background-color` | as `color`, or `transparent` | `p`, `div` and other containers, `blockquote`, `pre`, `table`, `td`, `th` |
|
|
172
222
|
| `padding`, `padding-top` … `padding-left` | one to four lengths in `px` or `pt` | the same |
|
|
173
|
-
| `margin`, `margin-top
|
|
223
|
+
| `margin`, `margin-top` … `margin-left` | one to four lengths in `px` or `pt`: top and bottom are added to `gap:`, left and right indent the block. A negative margin is drawn as none | blocks, and a floated `img` |
|
|
174
224
|
| `border` | `1px solid #ccc` in any order, `none` | `table` (every cell), `td`, `th` |
|
|
175
225
|
| `width` | `px`, `pt`, `%`, `auto` | `img`, `table`, and `td`/`th` (column widths, when every cell of the first row has one) |
|
|
226
|
+
| `float` | `left`, `right`, `none` | `img` only: the text that follows wraps beside it. Its `margin`, with `margin-top` … `margin-left` over it, is kept around it, else `styles: { img: { float_margin: 8 } }` towards the text |
|
|
176
227
|
| `page-break-before`, `page-break-after`, `break-before`, `break-after` | `always`, `page`, `auto` | blocks |
|
|
177
228
|
| `page-break-inside`, `break-inside` | `avoid`, `auto` | blocks |
|
|
229
|
+
| `column-count`, `columns` | a number of columns, `auto` (a column width is not read) | `div` and other containers, `p`, headings, lists, tables, `blockquote`, `pre` |
|
|
230
|
+
| `column-gap` | a length in `px` or `pt`, `normal` | the same |
|
|
178
231
|
|
|
179
|
-
|
|
180
|
-
|
|
232
|
+
`margin` and its sides are read in the order of the cascade: a side wins over a `margin` declared
|
|
233
|
+
before it or by a lesser rule, and a `margin` takes back the sides declared before it.
|
|
234
|
+
`<font color size>`, `<center>` and `<img align="left|right">` (a float) are read the same way.
|
|
235
|
+
Everything else (`display`, `float` on anything but an image, `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `auto` margins, a negative `margin-left` or `margin-right`, `url()` values,
|
|
181
236
|
inline backgrounds) is ignored and named in the `UnsupportedCss` warning, so `strict` catches
|
|
182
237
|
content that expects more than this. No value is ever fetched: `url()` and `@import` are dropped.
|
|
183
238
|
|
|
@@ -284,7 +339,8 @@ signature_field "signature", label: "Signature of the applicant"
|
|
|
284
339
|
- Every widget carries its own appearance, so the form looks the same in every viewer. Text is set
|
|
285
340
|
in the text style around the field, in the document's own fonts: embedded, with the fallbacks
|
|
286
341
|
applied per character, so a value in any script the fonts cover is drawn (`/V` holds it as
|
|
287
|
-
Unicode).
|
|
342
|
+
Unicode). A character no font has is drawn as `.notdef` inside a `Span` whose `ActualText` is the
|
|
343
|
+
character, as in any other text, so the appearance still extracts as written. Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
|
|
288
344
|
edited values; ZapfDingbats is listed for the ones that redraw a button's mark, never embedded
|
|
289
345
|
and never used by the appearances themselves.
|
|
290
346
|
- A field that can be edited keeps printable ASCII and Latin-1 in its font beyond the value it shows
|
|
@@ -301,6 +357,10 @@ signature_field "signature", label: "Signature of the applicant"
|
|
|
301
357
|
signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
|
|
302
358
|
- `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
|
|
303
359
|
`false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
|
|
360
|
+
- In tests, `document.fields` is what the form was filled with, and `have_pdf_text("Astrid", fields:
|
|
361
|
+
true)` or `assert_pdf_text pdf, "Astrid", fields: true` finds what a field shows on the page. A
|
|
362
|
+
value is drawn by its widget and is no part of the page content, so the text leaves it out
|
|
363
|
+
without `fields: true` (see [Testing](#testing)).
|
|
304
364
|
|
|
305
365
|
## Components
|
|
306
366
|
|
|
@@ -435,7 +495,9 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
|
|
|
435
495
|
- `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the
|
|
436
496
|
page number).
|
|
437
497
|
- `html`/`markdown` tag headings `H1`–`H6` (with or without `bookmarks: true`) and block quotes
|
|
438
|
-
`BlockQuote`, and give images their `alt`.
|
|
498
|
+
`BlockQuote`, and give images their `alt`. In `html`, `<img alt="">` is decoration as `alt: false`
|
|
499
|
+
is (an `<img>` without the attribute is a description missing). Markdown has no way to say so:
|
|
500
|
+
`` is a description missing, and decoration goes through `html` or `image`.
|
|
439
501
|
- Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
|
|
440
502
|
outside any element are layout artifacts.
|
|
441
503
|
- `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
|
|
@@ -443,8 +505,15 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
|
|
|
443
505
|
`dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
|
|
444
506
|
`pdf:Producer`. PDF/A and PDF/UA identification lives there (see
|
|
445
507
|
[PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out.
|
|
446
|
-
- An image or drawing without `alt
|
|
447
|
-
|
|
508
|
+
- An image or drawing without `alt:`, or with a blank one (`alt: ""`, whitespace alone, which
|
|
509
|
+
veraPDF would accept), is a `Warnings::MissingAlt`; `alt: false` is how decoration is marked.
|
|
510
|
+
- A heading level that is skipped is a `Warnings::SkippedHeading` (`level`, `allowed`, `page`): the
|
|
511
|
+
first heading is `heading: 1` and a heading is at most one level below the heading before it
|
|
512
|
+
(`1`, then `3` skips `2`). Going back up is free (`3`, then `1`). Headings are read in the order of
|
|
513
|
+
the structure tree, inside sections, lists, table cells, columns and floats; those of headers,
|
|
514
|
+
footers and page templates are artifacts and do not count.
|
|
515
|
+
- A missing `lang` is a `Warnings::MissingLanguage`. All three are warnings, so `strict` catches them,
|
|
516
|
+
and `conformance :pdf_ua1` raises on the first two.
|
|
448
517
|
- Untagged documents (the default) are written exactly as before.
|
|
449
518
|
|
|
450
519
|
Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
|
|
@@ -490,16 +559,23 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
|
|
|
490
559
|
- **PDF/UA-1** (ISO 14289): turns `tagged` on, needs `metadata title:` and `lang:`, writes
|
|
491
560
|
`pdfuaid:part`, shows the title in the viewer, orders tabs by structure (`/Tabs /S`) and gives
|
|
492
561
|
every link annotation a description (`/Contents`: the URL, or the target page). A figure without
|
|
493
|
-
`alt:` raises; mark decoration with `alt: false
|
|
562
|
+
`alt:` or with a blank one raises (7.3); mark decoration with `alt: false`, or `<img alt="">` in
|
|
563
|
+
`html`. A heading level that is skipped raises (7.4.2): the first heading is `heading: 1`, and a
|
|
564
|
+
heading is at most one level below the heading before it. PDF/A alone asks for neither.
|
|
565
|
+
Encryption is allowed.
|
|
494
566
|
- Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
|
|
495
567
|
- Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
|
|
496
568
|
field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
|
|
497
569
|
a font book (`Forms::Field.new` placed with `canvas.widget`) raises, since it draws with the
|
|
498
570
|
standard Helvetica.
|
|
571
|
+
- A character no font has raises at every level, naming the character and the family: it draws as
|
|
572
|
+
`.notdef`, which text may not reference under PDF/A (6.2.11.8) or PDF/UA (7.21.8). Add a font or
|
|
573
|
+
`font_fallbacks` that covers it. Whitespace a font lacks draws as a blank and is accepted.
|
|
499
574
|
- Without `conformance` nothing changes: the output is byte for byte what it was.
|
|
500
575
|
|
|
501
576
|
`bundle exec rake verify:conformance` renders `examples/invoice.rb` as PDF/A-3b, and
|
|
502
|
-
`examples/report.rb
|
|
577
|
+
`examples/report.rb`, `examples/form.rb`, `examples/article.rb` (floats) and `examples/newsletter.rb`
|
|
578
|
+
(columns) as PDF/A-3b plus PDF/UA-1, and validates them with
|
|
503
579
|
[veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
|
|
504
580
|
your own documents the same way:
|
|
505
581
|
|
|
@@ -634,17 +710,71 @@ gem "stationery", require: "stationery/rails"
|
|
|
634
710
|
|
|
635
711
|
### Large documents
|
|
636
712
|
|
|
713
|
+
A render holds what it still has to paint. The nodes of a page are let go once the page is
|
|
714
|
+
painted, a table measures its rows as pages reach them, the fonts forget the lines they shaped
|
|
715
|
+
after 64 KB of text, and a page nothing paints on again (no header, footer or page template, no
|
|
716
|
+
contents page number waiting on it) keeps its deflated content stream instead of its operators.
|
|
717
|
+
None of that changes a byte of the file.
|
|
718
|
+
|
|
637
719
|
`to_pdf { |chunk| … }` streams the file to the block in pieces as it is written and answers the
|
|
638
|
-
number of bytes: the first bytes leave sooner and no output buffer is built.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
`
|
|
720
|
+
number of bytes: the first bytes leave sooner and no output buffer is built. Every page is laid
|
|
721
|
+
out and painted before the first byte. The streamed file lists its objects in the order they
|
|
722
|
+
were written, a page's before the next page's and fonts, the structure tree and the catalog
|
|
723
|
+
last; the String keeps them in numbered order. Both are the same document. A signed document
|
|
724
|
+
cannot go to a block, since the signature covers every byte (`ArgumentError`). `to_pdf`,
|
|
725
|
+
`to_pdf(path)` and `to_pdf(io)` build the String and return it. In a controller with
|
|
726
|
+
`ActionController::Live`, `document.to_pdf { |chunk| response.stream.write(chunk) }` streams a
|
|
727
|
+
download; `send_pdf` and `render pdf:` buffer.
|
|
728
|
+
|
|
729
|
+
`to_pdf(incremental: true)`, or `incremental` at class level, writes each page's content as soon
|
|
730
|
+
as the page is painted and lets go of it; with a block it is handed over before the next page is
|
|
731
|
+
painted. It is for long documents with headers, footers, page templates or a table of contents:
|
|
732
|
+
those are painted once every page is known, so without it such a document holds the operators
|
|
733
|
+
of every page until then.
|
|
734
|
+
|
|
735
|
+
```ruby
|
|
736
|
+
class StatementPdf < Stationery::Document
|
|
737
|
+
incremental # or to_pdf(incremental: true) for one render
|
|
738
|
+
footer { |page| text "Page #{page.number} of #{page.count}" }
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
StatementPdf.new(account).to_pdf { |chunk| response.stream.write(chunk) }
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
- The file is the same document written another way: page contents first, then fonts, the page
|
|
745
|
+
tree, the outline and the catalog. What is painted once every page is known is a content
|
|
746
|
+
stream of its own, under or over the page's body, so a page has up to three and the file is
|
|
747
|
+
larger (1.5 → 1.7 MB for 1,039 pages with a footer).
|
|
748
|
+
- To a block, bytes leave while pages are painted: an error raised on a late page leaves the
|
|
749
|
+
block with the start of a file.
|
|
750
|
+
- A render that has to be checked before anything is written takes the usual path, as if
|
|
751
|
+
`incremental:` were not given: one with `conformance:` (PDF/A, PDF/UA, Factur-X: the audit
|
|
752
|
+
reads the painted pages), one with `sign:` (the signature covers the finished file), and a
|
|
753
|
+
`strict` one that goes to a block (a warning on the last page has to stop the first byte).
|
|
754
|
+
`strict` to a String, a path or an IO is incremental and raises before anything is written.
|
|
755
|
+
- A document with none of them is written the same either way and gains nothing in memory.
|
|
756
|
+
|
|
757
|
+
Peak resident memory of one render in a fresh process, and the megabytes still alive when
|
|
758
|
+
pagination ends (`bundle exec rake memory`; Apple M2 Max, Ruby 3.4.2 +YJIT, on a busy machine:
|
|
759
|
+
two runs of the same render peak up to a fifth apart, what is alive repeats):
|
|
760
|
+
|
|
761
|
+
| Document | Pages | | Before | Now | `incremental: true` |
|
|
762
|
+
|---|---:|---|---:|---:|---:|
|
|
763
|
+
| Headings and paragraphs | 1,000 | peak | 391 MB | 111 MB | 108 MB |
|
|
764
|
+
| | | alive | 216 MB | 17 MB | 14 MB |
|
|
765
|
+
| | 5,001 | peak | 1,556 MB | 350 MB | 415 MB |
|
|
766
|
+
| | | alive | 1,062 MB | 41 MB | 25 MB |
|
|
767
|
+
| The same with a footer and a table of contents | 1,039 | peak | 397 MB | 145 MB | 106 MB |
|
|
768
|
+
| | | alive | 222 MB | 44 MB | 11 MB |
|
|
769
|
+
| | 5,189 | peak | 1,643 MB | 614 MB | 446 MB |
|
|
770
|
+
| | | alive | 1,080 MB | 180 MB | 20 MB |
|
|
771
|
+
| One table of 33,000 rows | 1,000 | peak | 901 MB | 653 MB | 648 MB |
|
|
772
|
+
| | | alive | 494 MB | 28 MB | 25 MB |
|
|
773
|
+
| One table of 165,000 rows | 5,000 | peak | 3,792 MB | 2,952 MB | 2,955 MB |
|
|
774
|
+
| | | alive | 2,437 MB | 65 MB | 49 MB |
|
|
775
|
+
|
|
776
|
+
What is left is the document as it was built: every node exists before the first page is
|
|
777
|
+
painted, and a table resolves its column widths from every cell.
|
|
648
778
|
|
|
649
779
|
Controllers gain `render pdf:` and `send_pdf`:
|
|
650
780
|
|
|
@@ -794,9 +924,9 @@ a figure space, a narrow no-break space, …) is drawn as a blank of the
|
|
|
794
924
|
character's conventional width, never as `.notdef`. Any other glyph no font
|
|
795
925
|
has is drawn as the family's `.notdef` and reported as a
|
|
796
926
|
`Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises
|
|
797
|
-
on it
|
|
798
|
-
|
|
799
|
-
written. Fallback covers every text element,
|
|
927
|
+
on it, and a `conformance` level raises `ConformanceError`). The characters
|
|
928
|
+
themselves travel as the `ActualText` of a `Span` around the glyphs, so the
|
|
929
|
+
text still extracts, copies and reads aloud as written. Fallback covers every text element,
|
|
800
930
|
table cell, list marker, table of contents entry and page template text;
|
|
801
931
|
direct `canvas.text` calls draw with the font they are given.
|
|
802
932
|
|
|
@@ -842,19 +972,109 @@ drawn, and between ideographic characters (CJK ideographs, kana, Hangul), keepin
|
|
|
842
972
|
a closing mark such as 。」 on the line before it and an opening bracket with what
|
|
843
973
|
follows, so Japanese, Chinese and Korean text wraps without spaces.
|
|
844
974
|
|
|
845
|
-
Images are JPEG (grey, RGB, CMYK)
|
|
846
|
-
mask)
|
|
975
|
+
Images are JPEG (grey, RGB, CMYK), PNG (every colour type, alpha as a soft
|
|
976
|
+
mask) and lossless WebP (decoded in Ruby and embedded like a PNG, alpha as a
|
|
977
|
+
soft mask; lossy and animated WebP raise `UnsupportedImage`). Parsed fonts and
|
|
978
|
+
images are cached per process.
|
|
847
979
|
|
|
848
980
|
A bitmap keeps its pixels, so a 1600 px photo drawn 160 pt wide ships all
|
|
849
981
|
1600 px at 720 ppi. An image drawn at more than twice the document's
|
|
850
982
|
`max_ppi` (300, or `images max_ppi: 220` at class level, `nil` to switch the
|
|
851
983
|
check off) is reported as a `Warnings::OversizedImage` naming the file, its
|
|
852
984
|
pixel width and the resolution it lands at. `images downscale: true` (or
|
|
853
|
-
`image(..., downscale: true)`) resamples a PNG to `max_ppi` at its drawn size
|
|
985
|
+
`image(..., downscale: true)`) resamples a PNG or a WebP to `max_ppi` at its drawn size
|
|
854
986
|
before embedding it, alpha included; a JPEG is embedded byte for byte, so
|
|
855
987
|
resize it before you embed it (an ActiveStorage variant per drawn size,
|
|
856
988
|
preprocessed, keeps a render to a download).
|
|
857
989
|
|
|
990
|
+
### Complex scripts: the shaper hook
|
|
991
|
+
|
|
992
|
+
Stationery places glyphs itself: one per character, the font's ligatures and single substitutions,
|
|
993
|
+
pair kerning. Arabic, Hebrew, Indic and Thai text need a shaper, and the gem does not have one. An
|
|
994
|
+
application that does (HarfBuzz) plugs it in, for a document class or for one render:
|
|
995
|
+
|
|
996
|
+
```ruby
|
|
997
|
+
class Invoice < Stationery::Document
|
|
998
|
+
shaper HarfBuzzShaper.new # inherited; `shaper nil` takes an inherited one away
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
Invoice.new.to_pdf(shaper: my_shaper) # this render only
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
A shaper is anything that answers `call`:
|
|
1005
|
+
|
|
1006
|
+
```ruby
|
|
1007
|
+
def call(text, font, size:, features:, language:, **)
|
|
1008
|
+
# => [Stationery::Shaper::Glyph.new(gid:, advance:, cluster:, x_offset: 0, y_offset: 0), …] or nil
|
|
1009
|
+
end
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
| Argument | What it is |
|
|
1013
|
+
|---|---|
|
|
1014
|
+
| `text` | One stretch of a line in one font and style |
|
|
1015
|
+
| `font` | A `Stationery::Shaper::Face`: `path` (the font file, nil when there is none), `index` (the face of a collection), `data` (the sfnt bytes; a WOFF is already unpacked), `units_per_em`, `postscript_name`, `glyph_count` |
|
|
1016
|
+
| `size:` | The size drawn at, in points |
|
|
1017
|
+
| `features:` | A frozen Hash of OpenType tags to switches: `"kern"` and `"liga"` always, following `kerning:` and `ligatures:` (letter spacing switches `liga` off), and the style's `features:` as `true` |
|
|
1018
|
+
| `language:` | The document's `metadata lang:`, or nil |
|
|
1019
|
+
|
|
1020
|
+
The answer is the glyphs in **visual order**, left to right. `gid` is the glyph id in the font,
|
|
1021
|
+
`advance` and the offsets are in font units, and `cluster` is the index, in characters, of the first
|
|
1022
|
+
character of `text` the glyph stands for; glyphs of the same cluster stand together for the
|
|
1023
|
+
characters up to the next cluster. A Hash of the same fields does for a `Glyph`. Answering `nil`
|
|
1024
|
+
declines a text, which is then drawn as without a shaper. Anything else (a glyph id the font does
|
|
1025
|
+
not have, a cluster outside the text, an advance that is not a number) raises
|
|
1026
|
+
`Stationery::ShaperError`. Direction and script are not passed, because stationery knows neither:
|
|
1027
|
+
the shaper works them out from the text.
|
|
1028
|
+
|
|
1029
|
+
What is shaped, and when:
|
|
1030
|
+
|
|
1031
|
+
- Line breaking works on the text as written and measures each word and each stretch of spaces on
|
|
1032
|
+
its own. Each line is then cut where the font or the style changes, and every such stretch is
|
|
1033
|
+
shaped whole. That one answer is the stretch's width (alignment, underline, link rectangle) and
|
|
1034
|
+
what is drawn, so the two always agree. The width a break was decided on is the sum of the words',
|
|
1035
|
+
which can differ a little from the shaped line's, as kerning across a space already does.
|
|
1036
|
+
- The shaper is asked once per text, size and set of features in a render, and must answer the
|
|
1037
|
+
same for the same.
|
|
1038
|
+
- Every text a document draws goes through it: `text`, table cells, lists, `html`, `markdown`,
|
|
1039
|
+
headers and footers, the table of contents, text in `svg`. Form fields do not: a viewer that
|
|
1040
|
+
redraws a field after an edit would not shape it either.
|
|
1041
|
+
|
|
1042
|
+
In the PDF the glyph ids are written as the shaper gave them. The font's widths stay its own, so an
|
|
1043
|
+
advance the shaper changed is a `TJ` adjustment, an x offset moves the pen before the glyph and back
|
|
1044
|
+
after it, and a y offset is a text rise (`Ts`) around it, which keeps marks in place under a
|
|
1045
|
+
synthetic oblique, a superscript or letter spacing. Glyphs the shaper placed are embedded whether or
|
|
1046
|
+
not a character maps to them. Where the ToUnicode map cannot give the text (glyphs out of logical
|
|
1047
|
+
order, several glyphs for one cluster, a glyph that already stands for another text, glyph 0) the
|
|
1048
|
+
glyphs are shown in a `Span` whose `ActualText` is the characters in logical order. Glyph 0 is
|
|
1049
|
+
reported as a `Warnings::MissingGlyph`, like any other.
|
|
1050
|
+
|
|
1051
|
+
The limits of a hook:
|
|
1052
|
+
|
|
1053
|
+
- The shaper reorders within the stretch it is given and nowhere else. Stretches on a line are placed
|
|
1054
|
+
left to right in the order written and lines break in that order, so there is no bidirectional
|
|
1055
|
+
reordering across a change of font or style; a right-to-left paragraph is set with `align: :right`,
|
|
1056
|
+
and the last line of a justified one is set left.
|
|
1057
|
+
- Fallback fonts are chosen per character from the fonts' cmaps before anything is shaped. Give the
|
|
1058
|
+
text the family that covers its script, or digits and punctuation the first family has are drawn
|
|
1059
|
+
from it, as stretches of their own.
|
|
1060
|
+
- Justification widens U+0020 spaces only (no kashida). Letter spacing is added after each cluster,
|
|
1061
|
+
so a mark stays on its base, but it pulls cursive letters apart.
|
|
1062
|
+
- Hyphenation and the breaking of a word wider than the line cut the text as written; the pieces are
|
|
1063
|
+
shaped as separate words.
|
|
1064
|
+
- Vertical advances are not read: text runs horizontally.
|
|
1065
|
+
- A reader that takes `ActualText` for the text (the gem's `Inspector`) gets it as written. poppler
|
|
1066
|
+
(`pdftotext` 26.09) and MuPDF (1.28) run their own reordering over it and return a right-to-left
|
|
1067
|
+
stretch reversed. pdf-reader's own `Page#text` drops a `Span` whose first glyph has no advance (a
|
|
1068
|
+
mark drawn first); the `Inspector` does not.
|
|
1069
|
+
|
|
1070
|
+
[`examples/shaping/harfbuzz_shaper.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/harfbuzz_shaper.rb)
|
|
1071
|
+
is an adapter for HarfBuzz through the [`harfbuzz-ruby`](https://github.com/ydah/harfbuzz) gem
|
|
1072
|
+
(`gem "harfbuzz-ruby"`, `require "harfbuzz"`; not the older `harfbuzz` gem, which answers to the same
|
|
1073
|
+
`require`), about a hundred lines to copy into an application; stationery does not depend on it. It
|
|
1074
|
+
was run with harfbuzz-ruby 1.1.0 and HarfBuzz 14.5.0 against Noto Sans Arabic and draws joined,
|
|
1075
|
+
right-to-left Arabic with its marks and with left-to-right digits inside it. It cuts a stretch into
|
|
1076
|
+
runs of one direction by its letters alone, not by the Unicode bidirectional algorithm.
|
|
1077
|
+
|
|
858
1078
|
## Testing
|
|
859
1079
|
|
|
860
1080
|
`stationery/rspec` and `stationery/minitest` read a rendered PDF back for
|
|
@@ -879,6 +1099,7 @@ RSpec.describe InvoicePdf do
|
|
|
879
1099
|
|
|
880
1100
|
it { is_expected.to have_pdf_text("Invoice INV-7") }
|
|
881
1101
|
it { is_expected.to have_pdf_text_on_page(2, /Total €[\d ,]+/) }
|
|
1102
|
+
it { is_expected.to have_pdf_text("Astrid Lindqvist", fields: true) } # with what the form fields show
|
|
882
1103
|
it { is_expected.to have_page_count(2) }
|
|
883
1104
|
it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
|
|
884
1105
|
it { is_expected.to have_image_count(1) }
|
|
@@ -906,6 +1127,7 @@ class InvoicePdfTest < Minitest::Test
|
|
|
906
1127
|
|
|
907
1128
|
assert_pdf_text pdf, "Invoice INV-7"
|
|
908
1129
|
refute_pdf_text pdf, "DRAFT"
|
|
1130
|
+
assert_pdf_text pdf, "Astrid Lindqvist", fields: true
|
|
909
1131
|
assert_page_count pdf, 2
|
|
910
1132
|
assert_pdf_link pdf, /acme\.test/
|
|
911
1133
|
assert_no_pdf_warnings pdf
|
|
@@ -937,6 +1159,20 @@ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own te
|
|
|
937
1159
|
between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
|
|
938
1160
|
`Figure` reads as its alt text.
|
|
939
1161
|
|
|
1162
|
+
`text` and `page_texts` are the text of the page content as pdf-reader lays it out, line by line. A
|
|
1163
|
+
`Span` with `ActualText` (a stretch a shaper reordered, characters no font has) reads as that text,
|
|
1164
|
+
once, whatever glyphs it shows.
|
|
1165
|
+
|
|
1166
|
+
A form field's value is not page content: its widget draws it, so `have_pdf_text("Astrid")` does not
|
|
1167
|
+
find the value of a `text_field`. `fields: true` reads what the form fields show as well, each where
|
|
1168
|
+
it is on its page: `have_pdf_text("Astrid", fields: true)`, `have_pdf_text_on_page(1, "Astrid",
|
|
1169
|
+
fields: true)`, `assert_pdf_text pdf, "Astrid", fields: true`, `refute_pdf_text`, and
|
|
1170
|
+
`Inspector#text(fields: true)` and `#page_texts(fields: true)`. What is read is the normal appearance
|
|
1171
|
+
of every widget that is not hidden, in the state the widget is in, and never a button's `Off` state
|
|
1172
|
+
(the marks of a `checkbox` and a `radio` are paths and read as nothing). Without `fields: true` the
|
|
1173
|
+
text is what it always was, and the failure of `have_pdf_text` over a text that a field shows says
|
|
1174
|
+
so.
|
|
1175
|
+
|
|
940
1176
|
## Why not Prawn, Chrome or Typst?
|
|
941
1177
|
|
|
942
1178
|
- **Prawn** is an imperative cursor API: every document does its own layout
|
|
@@ -1023,19 +1259,32 @@ table render under StackProf (wall mode; `MODE=cpu` or `MODE=object` for the oth
|
|
|
1023
1259
|
`SGHTMLTOPDF=0 bundle exec rake bench` leaves the third engine out, as does a machine
|
|
1024
1260
|
without its gem.
|
|
1025
1261
|
|
|
1262
|
+
A lossless WebP is decoded in Ruby when it is first loaded, which a JPEG or an opaque PNG
|
|
1263
|
+
(both passed through) never is: a 1000 × 1000 px image takes 0.2 to 0.4 s on the machine
|
|
1264
|
+
above, by what is in it, and the image cache keeps it for the renders that follow.
|
|
1265
|
+
|
|
1026
1266
|
Time depends on the machine, so CI holds what does not: `bundle exec rake metrics`
|
|
1027
1267
|
renders six fixed documents and compares the objects each render allocates, its
|
|
1028
1268
|
page count and its bytes with `benchmark/baseline.json` (allocations may grow 3%,
|
|
1029
1269
|
bytes 1%, pages not at all). A change that moves them on purpose records a new
|
|
1030
1270
|
baseline with `bundle exec rake metrics:update` and says why in the commit.
|
|
1031
1271
|
|
|
1272
|
+
`bundle exec rake memory` reports what long documents hold while they render
|
|
1273
|
+
(`PAGES=5000` for more than its 1,000 pages): the peak resident set size of a render in
|
|
1274
|
+
a fresh process, and what is alive when building, pagination and writing end. It
|
|
1275
|
+
moves with the machine and gates nothing; the figures are under
|
|
1276
|
+
[Large documents](#large-documents).
|
|
1277
|
+
|
|
1032
1278
|
## Limitations
|
|
1033
1279
|
|
|
1034
1280
|
Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert to `.ttf` or
|
|
1035
1281
|
`.woff`); shaping stops at pair kerning and single or ligature substitutions (`liga` by default,
|
|
1036
1282
|
`smcp`, `onum`, `tnum`, `ss01`… on request), so contextual alternates (`calt`, `clig`, `frac`) do
|
|
1037
|
-
nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph
|
|
1038
|
-
|
|
1283
|
+
nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph unless the
|
|
1284
|
+
application brings a shaper (`shaper`, see [the shaper hook](#complex-scripts-the-shaper-hook): the gem
|
|
1285
|
+
shapes none of them itself), and colour or emoji glyphs no font
|
|
1286
|
+
in the chain has are drawn as `.notdef` and reported. Text runs left to right: a shaper orders the glyphs
|
|
1287
|
+
within a stretch of one font and style, nothing reorders stretches or lines (CJK text wraps between
|
|
1039
1288
|
ideographs, but there is no vertical layout); hyphenation
|
|
1040
1289
|
patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
|
|
1041
1290
|
and justification only widens spaces.
|
|
@@ -1044,15 +1293,27 @@ SVG covers the shapes, gradients, text, stylesheets, `use`/`symbol` sprites and
|
|
|
1044
1293
|
sets and exports use, nothing else: `image`, `mask`, `pattern`, `filter` and `textPath` are skipped
|
|
1045
1294
|
and reported, text inside a `clipPath` does not clip, and the shapes of a clip path join into one
|
|
1046
1295
|
path, so overlapping shapes wound in opposite directions cancel where they overlap.
|
|
1047
|
-
Images are JPEG
|
|
1048
|
-
|
|
1296
|
+
Images are JPEG, PNG (non-interlaced) and lossless WebP (not lossy or animated WebP, and no more
|
|
1297
|
+
than 33 megapixels) and never fetched from a URL; a JPEG is embedded at its
|
|
1298
|
+
source resolution (only PNG and WebP can be downscaled), so an oversized one is reported, not resized. `html` reads a fixed subset of CSS (colours, sizes, weights, alignment, margins, padding, table
|
|
1049
1299
|
borders and widths, page breaks; see [What CSS is read](#what-css-is-read)), not a layout
|
|
1050
|
-
engine's worth: no `display`,
|
|
1300
|
+
engine's worth: no `display`, positioning, `font-family`, `auto` or negative margins or selectors
|
|
1301
|
+
with combinators, and floats for images only, with their `margin` around them.
|
|
1051
1302
|
`markdown` reads no CSS, and raw HTML inside Markdown stays literal text.
|
|
1052
1303
|
|
|
1053
1304
|
Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
|
|
1054
|
-
splits only when every column can; a rotated box and a `stack` move to the next page whole.
|
|
1055
|
-
|
|
1305
|
+
splits only when every column can; a rotated box and a `stack` move to the next page whole.
|
|
1306
|
+
`columns` balances to the shortest height that holds the content and fills the columns evenly
|
|
1307
|
+
from the first one on (ten lines in three columns are 4, 3 and 3): a column takes what fits the
|
|
1308
|
+
height balanced for it and the columns after it, so one that ends above a block that cannot
|
|
1309
|
+
split may stay shorter than the column after it. It has columns of one width, nothing spanning
|
|
1310
|
+
them (end the block, write the full-width content, start another) and no column break of its
|
|
1311
|
+
own; a spacer that lands at the top of a column keeps its height. Text
|
|
1312
|
+
wraps around floated images and boxes, along their rectangles, never along a shape; beside a float
|
|
1313
|
+
a table, a row, `columns` and a box with a background, a border or a size of its own are blocks of
|
|
1314
|
+
the width that is left, all the way down: a box is painted after the float written before it, so
|
|
1315
|
+
one that kept the full width would paint its background over the float (see
|
|
1316
|
+
[Floats](#floats)). Link and form-widget rectangles stay in page space inside `rotate`
|
|
1056
1317
|
and `transform`, and `shadow:` is stacked rectangles, not a blur.
|
|
1057
1318
|
|
|
1058
1319
|
PDF: PDF/A-2b, PDF/A-3b and PDF/UA-1 only (no PDF/A-1, no level A or U, no PDF/UA-2, no PDF/X) and
|
data/lib/stationery/builder.rb
CHANGED
|
@@ -56,6 +56,15 @@ module Stationery
|
|
|
56
56
|
node
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Hands the finished root over and forgets it: the paginator is then the
|
|
60
|
+
# only holder of the nodes, so those of a painted page can be collected.
|
|
61
|
+
def release
|
|
62
|
+
root = @root
|
|
63
|
+
@root = nil
|
|
64
|
+
@containers = []
|
|
65
|
+
root
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
def within(container)
|
|
60
69
|
@containers << container
|
|
61
70
|
yield
|