stationery 0.11.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ebbefcf328d01eacf608d65fe46e05ac3561d335ae3ea255805afcb4fef66d2
4
- data.tar.gz: 37b7e8db370b6f1d82c9b2fd493300f4194be46006c6bd29456a3002774af3f3
3
+ metadata.gz: b208319414f7dea1a6af8a89b47aec788e1135ace442031a1d05623240483d7c
4
+ data.tar.gz: f9d15f729afcfd2fa6861b01b365462b4a5d413a4ce9b6fb101c2b7c3b9f68db
5
5
  SHA512:
6
- metadata.gz: 21b371d2b1cd3a829ac46991185c0e9b4406b67089984d5ea35dc8e60d9bacb7e0f97e2cec744e37f2c9bed39264b32af83f75e765eb2053d81834082c8aae38
7
- data.tar.gz: b2aa8f17f1d896c7e9796ff960cf2777db76f20270f05ffce4dc28da9aa370973e4c6ed7aa84ccfe20da1b8579aa9183ecc6922ac9ddecf7da282007faac6a19
6
+ metadata.gz: fca53eecf78b67b65c7bb9b9582d625f6e693ba9ff29264423f9c1028d3ea8b2477d69af7858edde8169b91b592d43a364d4874703cf719173158c09f187f4b1
7
+ data.tar.gz: 9bd72b967a7e1cd32acf650e5662d009cf792e23f1fef9cec3df579b74fad916f2ed4a0d6d96bdd0b0bbefcecc85f7d277510ff69b486c742d0d00ae169ac8da
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
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 (`![](photo.png)`) 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 `![](photo.png)` 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
+
3
18
  ## 0.11.0 (2026-09-28)
4
19
 
5
20
  Text wrap around images, balanced columns, lossless WebP, a shaper hook for complex scripts, and far less memory for long documents.
data/README.md CHANGED
@@ -83,7 +83,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
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
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; `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
+ | `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. |
87
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. |
88
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. |
89
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`). |
@@ -136,7 +136,14 @@ text more # around the pull quote
136
136
  - Text (paragraphs, headings, the text inside a `group` or an `html` block) wraps: every line takes
137
137
  the width left at its own top and is aligned and justified in it. A paragraph whose widest word
138
138
  does not fit beside the floats starts below them.
139
- - Anything else (a `box`, `row`, `columns`, `table`, list item, `rule`, `image`, `svg`, form field) is a block:
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:
140
147
  it goes beside the float in the width that is left, and keeps that width all the way down, when
141
148
  its own width (or the least its content takes) fits there; else it starts below the float. A
142
149
  `spacer` takes its height beside the float; a `page_break` ends the page and the float with it.
@@ -147,7 +154,8 @@ text more # around the pull quote
147
154
  - Across pages a float never splits. When it does not fit what is left of the page, or the content
148
155
  after it could not start beside it there, it moves to the next page with that content. A
149
156
  paragraph beside a float splits between lines as always (`orphans:`, `widows:`); the lines carried
150
- over are wrapped again at the full width, because the float stayed behind.
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.
151
159
  - In a tagged PDF the float is where it was written: an image is a `Figure`, a box has its `role:`.
152
160
 
153
161
  A line beside a float is taken to be as tall as a line of the paragraph's own style when its width
@@ -212,17 +220,19 @@ ignored and reported. At-rules are skipped, except that rules inside `@media pri
212
220
  | `text-align` | `left`, `center`, `right`, `justify` | paragraphs, headings, cells, images; inherited from a container |
213
221
  | `background-color` | as `color`, or `transparent` | `p`, `div` and other containers, `blockquote`, `pre`, `table`, `td`, `th` |
214
222
  | `padding`, `padding-top` … `padding-left` | one to four lengths in `px` or `pt` | the same |
215
- | `margin`, `margin-top`, `margin-bottom` | lengths in `px` or `pt`; top and bottom only, added to `gap:` | blocks |
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` |
216
224
  | `border` | `1px solid #ccc` in any order, `none` | `table` (every cell), `td`, `th` |
217
225
  | `width` | `px`, `pt`, `%`, `auto` | `img`, `table`, and `td`/`th` (column widths, when every cell of the first row has one) |
218
- | `float` | `left`, `right`, `none` | `img` only: the text that follows wraps beside it. Its `margin` (all four sides) is kept around it, else `styles: { img: { float_margin: 8 } }` towards the text |
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 |
219
227
  | `page-break-before`, `page-break-after`, `break-before`, `break-after` | `always`, `page`, `auto` | blocks |
220
228
  | `page-break-inside`, `break-inside` | `avoid`, `auto` | blocks |
221
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` |
222
230
  | `column-gap` | a length in `px` or `pt`, `normal` | the same |
223
231
 
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.
224
234
  `<font color size>`, `<center>` and `<img align="left|right">` (a float) are read the same way.
225
- Everything else (`display`, `float` on anything but an image, `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `url()` values,
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,
226
236
  inline backgrounds) is ignored and named in the `UnsupportedCss` warning, so `strict` catches
227
237
  content that expects more than this. No value is ever fetched: `url()` and `@import` are dropped.
228
238
 
@@ -347,6 +357,10 @@ signature_field "signature", label: "Signature of the applicant"
347
357
  signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
348
358
  - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
349
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)).
350
364
 
351
365
  ## Components
352
366
 
@@ -481,7 +495,9 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
481
495
  - `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the
482
496
  page number).
483
497
  - `html`/`markdown` tag headings `H1`–`H6` (with or without `bookmarks: true`) and block quotes
484
- `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
+ `![](photo.png)` is a description missing, and decoration goes through `html` or `image`.
485
501
  - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
486
502
  outside any element are layout artifacts.
487
503
  - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
@@ -489,8 +505,15 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
489
505
  `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
490
506
  `pdf:Producer`. PDF/A and PDF/UA identification lives there (see
491
507
  [PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out.
492
- - An image or drawing without `alt:` (`Warnings::MissingAlt`) and a missing `lang`
493
- (`Warnings::MissingLanguage`) are warnings, so `strict` catches them.
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.
494
517
  - Untagged documents (the default) are written exactly as before.
495
518
 
496
519
  Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
@@ -536,7 +559,10 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
536
559
  - **PDF/UA-1** (ISO 14289): turns `tagged` on, needs `metadata title:` and `lang:`, writes
537
560
  `pdfuaid:part`, shows the title in the viewer, orders tabs by structure (`/Tabs /S`) and gives
538
561
  every link annotation a description (`/Contents`: the URL, or the target page). A figure without
539
- `alt:` raises; mark decoration with `alt: false`. Encryption is allowed.
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.
540
566
  - Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
541
567
  - Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
542
568
  field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
@@ -1038,8 +1064,8 @@ The limits of a hook:
1038
1064
  - Vertical advances are not read: text runs horizontally.
1039
1065
  - A reader that takes `ActualText` for the text (the gem's `Inspector`) gets it as written. poppler
1040
1066
  (`pdftotext` 26.09) and MuPDF (1.28) run their own reordering over it and return a right-to-left
1041
- stretch reversed. pdf-reader, and so `Inspector#text`, drops a `Span` whose first glyph has no
1042
- advance (a mark drawn first); `Inspector#structure` does not.
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.
1043
1069
 
1044
1070
  [`examples/shaping/harfbuzz_shaper.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/harfbuzz_shaper.rb)
1045
1071
  is an adapter for HarfBuzz through the [`harfbuzz-ruby`](https://github.com/ydah/harfbuzz) gem
@@ -1073,6 +1099,7 @@ RSpec.describe InvoicePdf do
1073
1099
 
1074
1100
  it { is_expected.to have_pdf_text("Invoice INV-7") }
1075
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
1076
1103
  it { is_expected.to have_page_count(2) }
1077
1104
  it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
1078
1105
  it { is_expected.to have_image_count(1) }
@@ -1100,6 +1127,7 @@ class InvoicePdfTest < Minitest::Test
1100
1127
 
1101
1128
  assert_pdf_text pdf, "Invoice INV-7"
1102
1129
  refute_pdf_text pdf, "DRAFT"
1130
+ assert_pdf_text pdf, "Astrid Lindqvist", fields: true
1103
1131
  assert_page_count pdf, 2
1104
1132
  assert_pdf_link pdf, /acme\.test/
1105
1133
  assert_no_pdf_warnings pdf
@@ -1131,6 +1159,20 @@ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own te
1131
1159
  between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
1132
1160
  `Figure` reads as its alt text.
1133
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
+
1134
1176
  ## Why not Prawn, Chrome or Typst?
1135
1177
 
1136
1178
  - **Prawn** is an imperative cursor API: every document does its own layout
@@ -1255,18 +1297,22 @@ Images are JPEG, PNG (non-interlaced) and lossless WebP (not lossy or animated W
1255
1297
  than 33 megapixels) and never fetched from a URL; a JPEG is embedded at its
1256
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
1257
1299
  borders and widths, page breaks; see [What CSS is read](#what-css-is-read)), not a layout
1258
- engine's worth: no `display`, positioning, `font-family` or selectors with combinators, and floats
1259
- for images only.
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.
1260
1302
  `markdown` reads no CSS, and raw HTML inside Markdown stays literal text.
1261
1303
 
1262
1304
  Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
1263
1305
  splits only when every column can; a rotated box and a `stack` move to the next page whole.
1264
- `columns` balances to the shortest height that holds the content and fills the columns in order
1265
- (ten lines in three columns are 4, 4 and 2), has columns of one width, nothing spanning them
1266
- (end the block, write the full-width content, start another) and no column break of its own; a
1267
- spacer that lands at the top of a column keeps its height. Text
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
1268
1312
  wraps around floated images and boxes, along their rectangles, never along a shape; beside a float
1269
- a table, box, row or list is a block of the width that is left, all the way down (see
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
1270
1316
  [Floats](#floats)). Link and form-widget rectangles stay in page space inside `rotate`
1271
1317
  and `transform`, and `shadow:` is stacked rectangles, not a blur.
1272
1318
 
@@ -52,9 +52,17 @@ module Stationery
52
52
  def empty? = @rules.empty?
53
53
 
54
54
  def declarations(element)
55
+ rules_for(element).reduce({}) { |merged, rule| merged.merge(rule.declarations) }
56
+ end
57
+
58
+ # The declarations of every rule that matches, the one that wins last.
59
+ def matching(element) = rules_for(element).map(&:declarations)
60
+
61
+ private
62
+
63
+ def rules_for(element)
55
64
  @rules.select { |rule| rule.selector.match?(element) }
56
65
  .sort_by { |rule| [rule.selector.specificity, rule.index] }
57
- .reduce({}) { |merged, rule| merged.merge(rule.declarations) }
58
66
  end
59
67
  end
60
68
  end
@@ -37,9 +37,13 @@ module Stationery
37
37
  # Properties read on text (inherited by everything inside the element).
38
38
  INLINE = %w[color font-size font-weight font-style text-decoration].freeze
39
39
  # Properties read on the block an element becomes.
40
- BLOCK = %w[text-align background-color margin margin-top margin-bottom padding padding-top padding-right
41
- padding-bottom padding-left border width page-break-before page-break-after break-before
42
- break-after page-break-inside break-inside column-count column-gap columns].freeze
40
+ BLOCK = %w[text-align background-color margin margin-top margin-right margin-bottom margin-left padding
41
+ padding-top padding-right padding-bottom padding-left border width page-break-before
42
+ page-break-after break-before break-after page-break-inside break-inside column-count column-gap
43
+ columns].freeze
44
+ # The sides `margin` sets, and the two that indent a block: a negative one is not drawn.
45
+ MARGINS = %w[margin-top margin-right margin-bottom margin-left].freeze
46
+ INDENTS = %w[margin-left margin-right].freeze
43
47
  FONT_SIZES = { "1" => 0.625, "2" => 0.8125, "3" => 1.0, "4" => 1.125, "5" => 1.5, "6" => 2.0,
44
48
  "7" => 3.0 }.freeze
45
49
  KEYWORD_SIZES = { "xx-small" => 0.5625, "x-small" => 0.625, "small" => 0.8125, "medium" => 1.0,
@@ -79,9 +83,17 @@ module Stationery
79
83
  end
80
84
 
81
85
  # The declarations that apply to `element`: legacy attributes, then the
82
- # stylesheet by specificity, then its inline style.
86
+ # stylesheet by specificity, then its inline style. A margin declared
87
+ # again stands where it was declared last, since `margin` and its sides
88
+ # are read in that order.
83
89
  def declarations(element)
84
- legacy(element).merge(@sheet.declarations(element), CSS.declarations(element.attributes["style"]))
90
+ layers = [legacy(element), *@sheet.matching(element), CSS.declarations(element.attributes["style"])]
91
+ layers.each_with_object({}) do |layer, merged|
92
+ layer.each do |name, value|
93
+ merged.delete(name) if name.start_with?("margin")
94
+ merged[name] = value
95
+ end
96
+ end
85
97
  end
86
98
 
87
99
  # [marks, block]: the text marks the element's content inherits and the
@@ -149,8 +161,9 @@ module Stationery
149
161
  case name
150
162
  when "text-align" then read(name, value, block, :align) { align(value) }
151
163
  when "background-color" then read(name, value, block, :background) { color(value) }
152
- when "margin" then read(name, value, block, :margin) { box(value) }
164
+ when "margin" then margin(value, block)
153
165
  when "padding" then read(name, value, block, :padding) { box(value) }
166
+ when *INDENTS then read(name, value, block, name.tr("-", "_").to_sym) { indent(value) }
154
167
  when /\A(margin|padding)-/ then read(name, value, block, name.tr("-", "_").to_sym) { points(value) }
155
168
  when "border" then read(name, value, block, :border) { border(value) }
156
169
  when "width" then read(name, value, block, :width) { width(value) }
@@ -244,6 +257,18 @@ module Stationery
244
257
  end
245
258
  end
246
259
 
260
+ # `margin` sets every side, so it takes back the sides declared before
261
+ # it; one with a value that is not read leaves them as they are.
262
+ def margin(value, block)
263
+ sides = read("margin", value, block, :margin) { box(value) }
264
+ MARGINS.each { |name| block.delete(name.tr("-", "_").to_sym) } if sides
265
+ end
266
+
267
+ def indent(value)
268
+ length = points(value)
269
+ length unless length&.negative?
270
+ end
271
+
247
272
  # One to four lengths as [top, right, bottom, left].
248
273
  def box(value)
249
274
  sides = value.split.map { |part| points(part) }
@@ -27,13 +27,13 @@ module Stationery
27
27
  ALIGN = /\A\s*(left|center|right|justify)\s*\z/i
28
28
  # What makes a container a block of its own instead of flattening into its parent.
29
29
  BOXED = %i[background padding padding_top padding_right padding_bottom padding_left margin margin_top
30
- margin_bottom break_before break_after keep_together columns].freeze
30
+ margin_right margin_bottom margin_left break_before break_after keep_together columns].freeze
31
31
  # What a block of its own takes besides.
32
32
  WITH_BOX = %i[column_gap].freeze
33
33
  INHERITED = %i[align].freeze
34
34
  # What an image takes from its style, and what a floated one takes too.
35
35
  IMAGE = %i[width align].freeze
36
- FLOATED = %i[width float margin margin_top margin_bottom].freeze
36
+ FLOATED = %i[width float margin margin_top margin_right margin_bottom margin_left].freeze
37
37
  EMPTY = {}.freeze
38
38
 
39
39
  def self.convert(root, css = Css.parse([])) = new(css).blocks_of(root, EMPTY, EMPTY)
@@ -129,11 +129,15 @@ module Stationery
129
129
  def image(attributes, style)
130
130
  return unless attributes["src"]
131
131
 
132
- Rich::Image.new(src: attributes["src"], alt: attributes["alt"],
132
+ Rich::Image.new(src: attributes["src"], alt: description(attributes["alt"]),
133
133
  width: dimension(attributes["width"]), height: dimension(attributes["height"]),
134
134
  style: style.slice(*(style[:float] ? FLOATED : IMAGE)))
135
135
  end
136
136
 
137
+ # An alt that is there and empty is how HTML marks decoration (false,
138
+ # as `image alt: false`); one that is not there is a description missing.
139
+ def description(alt) = alt && !Tagging.blank?(alt) && alt
140
+
137
141
  def dimension(value) = value&.[](/\A\s*(\d+)/, 1)&.to_i
138
142
 
139
143
  def table(element, marks, inherited, style)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Box < Node
6
+ # A box beside floats. One that paints nothing of its own and takes the
7
+ # width it is given is a block as CSS knows it: it keeps the full width
8
+ # of its flow and hands the floats to its content (`exclusions:`, from
9
+ # its own top), which wraps beside them and takes the full width below
10
+ # them. The content meets the floats inside the padding: padding that
11
+ # lies under a float is not added to it.
12
+ #
13
+ # Any other box is a block of the width the floats leave, all the way
14
+ # down: one with a background, a border, a shadow or a link, which
15
+ # would be painted over a float that was painted before it, and one
16
+ # with a width or a height of its own, a rotated or a clipped one, or
17
+ # one that places its content by `valign:`.
18
+ module Wrapping
19
+ def wraps? = !sized? && !painted? && @content.wraps?
20
+
21
+ private
22
+
23
+ def sized?
24
+ !(@width_spec.nil? && @height.nil?) || !@rotate.zero? || @overflow != :visible || @valign != :top
25
+ end
26
+
27
+ def painted? = !(@background || @border || @shadow || @link).nil?
28
+
29
+ # Where the content goes beside floats, and what they take from it
30
+ # there: measure, split and paint place it by this one slot. nil
31
+ # without floats.
32
+ def beside(width, exclusions)
33
+ return unless exclusions
34
+
35
+ top, right, _bottom, left = insets
36
+ Flow::Placement::Slot.new(top:, left:, width: inner_width(width),
37
+ exclusions: exclusions.inset(top:, right:, left:))
38
+ end
39
+
40
+ def content_height(width, exclusions)
41
+ slot = beside(width, exclusions)
42
+ slot ? slot.measure(@content) : @content.measure(inner_width(width))
43
+ end
44
+
45
+ def split_content(width, height, fresh, exclusions)
46
+ slot = beside(width, exclusions)
47
+ slot ? slot.split(@content, height, fresh:) : @content.split(inner_width(width), height, fresh:)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "box/wrapping"
4
+
3
5
  module Stationery
4
6
  module Layout
5
7
  # A container with padding, background, border and corner radius. A box
@@ -15,6 +17,8 @@ module Stationery
15
17
  # never splits); `shadow:` paints a soft drop shadow under it, taking no
16
18
  # space; `overflow: :hidden` clips the content to the rounded outline.
17
19
  class Box < Node
20
+ include Wrapping
21
+
18
22
  BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
19
23
  SHADOW = { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }.freeze
20
24
  SHADOW_LAYERS = 8
@@ -68,16 +72,16 @@ module Stationery
68
72
 
69
73
  def prefer_whole? = break_inside.nil? && splittable?
70
74
 
71
- def split(width, height, fresh: false)
72
- return [self, nil] if measure(width) <= height + EPSILON
75
+ def split(width, height, fresh: false, exclusions: nil)
76
+ return [self, nil] if measure(width, exclusions:) <= height + EPSILON
73
77
 
74
78
  cut = @open | [:bottom]
75
79
  available = height - vertical(cut)
76
- head, tail = @content.split(inner_width(width), available, fresh:)
80
+ head, tail = split_content(width, available, fresh, exclusions)
77
81
  return [nil, self] unless head
78
- return [with_content(head), nil] unless tail || @min_height.to_f > height + EPSILON
82
+ return closed(width, height, head, fresh, exclusions) unless tail || @min_height.to_f > height + EPSILON
79
83
 
80
- fragments(width, height, head, tail || Flow.new, cut)
84
+ fragments(width, height, head, tail || Flow.new, cut, exclusions)
81
85
  end
82
86
 
83
87
  # An empty continuation: open at the top, without a floor.
@@ -87,19 +91,24 @@ module Stationery
87
91
  def with_content(content, open = @open) = dup.reopen(content, open)
88
92
  def with_open(*sides) = with_content(@content, @open | sides)
89
93
 
90
- def measure(width)
91
- @height || memoize_by_width(width) { [@content.measure(inner_width(width)) + vertical, @min_height || 0].max }
94
+ def measure(width, exclusions: nil)
95
+ return @height if @height
96
+
97
+ memoize_by_width(exclusions ? [width, exclusions] : width) do
98
+ [content_height(width, exclusions) + vertical, @min_height || 0].max
99
+ end
92
100
  end
93
101
 
94
- def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
95
- height ||= measure(width)
102
+ def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, exclusions: nil, **)
103
+ height ||= measure(width, exclusions:)
104
+ slot = beside(width, exclusions)
96
105
  canvas.rotate(@rotate, around: [x + (width / 2.0), y + (height / 2.0)]) do
97
106
  canvas.structure(@tag) do
98
107
  canvas.structure(@link_tag) do
99
108
  paint_shadow(canvas, x, y, width, height)
100
109
  paint_background(canvas, x, y, width, height)
101
110
  paint_border(canvas, x, y, width, height)
102
- paint_content(canvas, x, y, width, height, valign || @valign)
111
+ slot ? slot.paint(@content, canvas, x, y) : paint_content(canvas, x, y, width, height, valign || @valign)
103
112
  end
104
113
  end
105
114
  paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
@@ -120,9 +129,26 @@ module Stationery
120
129
 
121
130
  def fragment(content, open, min_height) = dup.reopen(content, open, min_height)
122
131
 
123
- def fragments(width, height, head, tail, cut)
132
+ # The content ends on this page. A head is open at the bottom, so the
133
+ # content was cut at the height less the top of the box alone: it fits
134
+ # with the box closed under it (what it dropped at the cut made the
135
+ # room), or the padding and border that close the box do not. Then the
136
+ # content is cut again with their room kept, and the last of it goes to
137
+ # the next page with them. Content that cannot be cut there moves whole;
138
+ # first on a fresh page it is kept as it is, and reported.
139
+ def closed(width, height, head, fresh, exclusions)
140
+ whole = with_content(head)
141
+ return [whole, nil] if whole.measure(width, exclusions:) <= height + EPSILON
142
+
143
+ head, tail = split_content(width, height - vertical, fresh, exclusions)
144
+ return fragments(width, height, head, tail, @open | [:bottom], exclusions) if head && tail
145
+
146
+ fresh ? [whole, nil] : [nil, self]
147
+ end
148
+
149
+ def fragments(width, height, head, tail, cut, exclusions)
124
150
  first = fragment(head, cut, @min_height && [@min_height, height].min).tap { |part| part.keep_with_next = nil }
125
- rest = @min_height.to_f - first.measure(width)
151
+ rest = @min_height.to_f - first.measure(width, exclusions:)
126
152
  [first, fragment(tail, @open | [:top], rest.positive? ? rest : nil)]
127
153
  end
128
154
 
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Columns
6
+ # Fills balanced columns evenly. Columns filled in order at the balanced
7
+ # height leave the last one what is left over: ten lines in three
8
+ # columns are 4, 4 and 2. Here the first column keeps what fits the
9
+ # height, what it leaves is balanced through the columns after it, and
10
+ # so on down to the last two: 4, 3 and 3. What cannot be shared goes to
11
+ # the earlier columns.
12
+ #
13
+ # Every column is cut by Flow#split from what the one before it left,
14
+ # so the result is a pour like any other and the break rules hold in
15
+ # it. Fitting is not monotonic in the height, so nothing is assumed of
16
+ # that pour: the columns filled in order are kept unless it places
17
+ # everything, is exactly as tall as they are (what follows the block
18
+ # stays where it is), and has no later column taller than an earlier
19
+ # one where they have none. They are also kept when their first column
20
+ # cannot be cut again below other content (a child too tall for the
21
+ # page).
22
+ #
23
+ # Each level is one Balancer search with a column less, so levelling
24
+ # pours at most (count - 2) * (MAX_PROBES + 1) times and cuts a first
25
+ # column count - 2 times. Two columns are even as they are: the second
26
+ # takes what the first left.
27
+ class Leveller
28
+ # `limit` is the tallest a column may be.
29
+ def initialize(pour, limit: Float::INFINITY)
30
+ @pour = pour
31
+ @limit = limit
32
+ end
33
+
34
+ # `poured` is the balanced pour: the columns filled in order.
35
+ def call(poured)
36
+ return poured unless poured.complete? && @pour.count > 2
37
+
38
+ height = [@limit, poured.height(@pour.width)].min
39
+ first, rest = @pour.first(height)
40
+ return poured unless first && rest
41
+
42
+ levelled = after(first, rest, height)
43
+ better?(levelled, poured) ? levelled : poured
44
+ end
45
+
46
+ private
47
+
48
+ def after(first, rest, height)
49
+ poured = self.class.new(rest, limit: height).call(Balancer.new(rest, limit: height).call)
50
+ Poured.new(columns: [first, *poured.columns], rest: poured.rest)
51
+ end
52
+
53
+ def better?(levelled, poured)
54
+ levelled.complete? && (levelled.height(@pour.width) - poured.height(@pour.width)).abs <= EPSILON &&
55
+ (descending?(levelled) || !descending?(poured))
56
+ end
57
+
58
+ def descending?(poured)
59
+ heights = poured.columns.map { |column| column.measure(@pour.width) }
60
+ heights.each_cons(2).all? { |taller, shorter| shorter <= taller + EPSILON }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -41,6 +41,13 @@ module Stationery
41
41
  end
42
42
  Poured.new(columns:, rest:)
43
43
  end
44
+
45
+ # [the first column at `height`, the pour of what it leaves through
46
+ # the columns after it (nil when it leaves nothing)].
47
+ def first(height)
48
+ head, tail = @flow.split(@width, height)
49
+ [head, tail && self.class.new(tail, @width, @count - 1)]
50
+ end
44
51
  end
45
52
  end
46
53
  end